@cortexkit/aft-opencode 0.57.2 → 0.58.1

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 (66) 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 +199 -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-live-reload.d.ts +315 -0
  8. package/dist/config-live-reload.d.ts.map +1 -0
  9. package/dist/config.d.ts +112 -54
  10. package/dist/config.d.ts.map +1 -1
  11. package/dist/entry/server.js +10449 -7872
  12. package/dist/hooks/auto-update-checker/checker.d.ts +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 +6 -0
  15. package/dist/hooks/auto-update-checker/index.d.ts.map +1 -1
  16. package/dist/index.d.ts +3 -2
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +10315 -7815
  19. package/dist/logger.d.ts.map +1 -1
  20. package/dist/lsp-auto-install.d.ts.map +1 -1
  21. package/dist/lsp-npm-table.d.ts +1 -1
  22. package/dist/lsp-npm-table.d.ts.map +1 -1
  23. package/dist/rpc/register.d.ts +7 -1
  24. package/dist/rpc/register.d.ts.map +1 -1
  25. package/dist/shared/read-vision.d.ts +10 -0
  26. package/dist/shared/read-vision.d.ts.map +1 -0
  27. package/dist/shared/status.d.ts +45 -0
  28. package/dist/shared/status.d.ts.map +1 -1
  29. package/dist/shared/subagent-detect.d.ts.map +1 -1
  30. package/dist/shutdown-hooks.d.ts +3 -9
  31. package/dist/shutdown-hooks.d.ts.map +1 -1
  32. package/dist/subc-tool-schemas.d.ts +9 -1
  33. package/dist/subc-tool-schemas.d.ts.map +1 -1
  34. package/dist/tool-perf.d.ts +1 -0
  35. package/dist/tool-perf.d.ts.map +1 -1
  36. package/dist/tool-registration.d.ts +19 -12
  37. package/dist/tool-registration.d.ts.map +1 -1
  38. package/dist/tools/ast.d.ts.map +1 -1
  39. package/dist/tools/bash.d.ts +1 -1
  40. package/dist/tools/bash.d.ts.map +1 -1
  41. package/dist/tools/bash_watch.d.ts +3 -0
  42. package/dist/tools/bash_watch.d.ts.map +1 -1
  43. package/dist/tools/definitions/v2.d.ts.map +1 -1
  44. package/dist/tools/hoisted.d.ts +9 -12
  45. package/dist/tools/hoisted.d.ts.map +1 -1
  46. package/dist/tools/inspect.d.ts +5 -7
  47. package/dist/tools/inspect.d.ts.map +1 -1
  48. package/dist/tools/permissions.d.ts.map +1 -1
  49. package/dist/tools/reading.d.ts.map +1 -1
  50. package/dist/tools/search.d.ts.map +1 -1
  51. package/dist/tui/v2-status.d.ts +1 -1
  52. package/dist/tui/v2-status.d.ts.map +1 -1
  53. package/dist/workflow-hints.d.ts +1 -7
  54. package/dist/workflow-hints.d.ts.map +1 -1
  55. package/package.json +8 -8
  56. package/src/entry/server-runtime.mjs +133 -34
  57. package/src/logger.ts +5 -6
  58. package/src/shared/read-vision.ts +100 -0
  59. package/src/shared/status.ts +83 -4
  60. package/src/shared/subagent-detect.ts +56 -1
  61. package/src/tui/index.tsx +4 -10
  62. package/src/tui/sidebar-view.tsx +36 -15
  63. package/src/tui/v2-status.ts +25 -4
  64. package/src/tui-compiled/index.tsx +3 -3
  65. package/src/tui-compiled/sidebar-view.tsx +168 -126
  66. package/src/tui-compiled/v2-status.ts +25 -4
@@ -1,19 +1,22 @@
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 { startOpenCodeLiveConfigReload } from "../config-live-reload.js";
17
+ import { debug, log, warn } from "../logger.js";
15
18
  import { resolvePluginVersion } from "../plugin-version.js";
16
- import { registerAftRpc } from "../rpc/register.js";
19
+ import { registerAftConfigErrorRpc, registerAftRpc } from "../rpc/register.js";
17
20
  import { hoistedV2ToolConsumers } from "../tools/hoisted/v2.js";
18
21
  import { createV2RuntimeConsumer } from "../wakes/runtime-consumer.js";
19
22
  import {
@@ -22,59 +25,145 @@ import {
22
25
  registerAftTools,
23
26
  } from "../tool-registration.js";
24
27
 
28
+ // The bridge environment (binary, storage migration, configure overrides, ONNX
29
+ // Runtime, LSP installs) comes from the bootstrap shared with the OpenCode 1
30
+ // entry; every one of those steps can be replaced here through overrides.
25
31
  const defaults = {
26
- buildConfigureParams: buildConfigTierConfigureParams,
32
+ ...defaultBridgeBootstrapDependencies,
27
33
  buildToolMap: buildAftToolDefinitions,
28
34
  registerTools: registerAftTools,
29
35
  registerRpc: registerAftRpc,
36
+ registerConfigErrorRpc: registerAftConfigErrorRpc,
30
37
  toolConsumers: (context) => ({
31
38
  ...hoistedV2ToolConsumers(context),
32
39
  ...createV2RuntimeConsumer(context),
33
40
  }),
34
41
  acquireBridge,
35
- loadConfig: loadAftConfig,
36
42
  releaseBridge,
37
- resolveBinary: findBinary,
43
+ startLiveConfigReload: startOpenCodeLiveConfigReload,
38
44
  resolvePoolOptions: resolveBridgePoolTransportOptions,
39
- resolveStorageRoot: resolveCortexKitStorageRoot,
40
45
  resolveVersion: () => resolvePluginVersion(import.meta.url),
41
46
  };
42
47
 
43
48
  async function bootLocation(context, location, dependencies) {
44
49
  const directory = location.directory;
45
- const config = dependencies.loadConfig(directory);
46
- if (config.enabled === false) return undefined;
50
+ // The V2 host has no session UI to deliver startup warnings into, so they
51
+ // go to the plugin log.
52
+ const notify = (message) => warn(message);
53
+ const bootstrap = await resolveBootstrapConfig(directory, notify, dependencies);
54
+ if (!bootstrap.ok) {
55
+ // The config error state: register the tool surface with every call
56
+ // failing, and acquire no bridge.
57
+ log(`AFT is in the config error state for ${directory}; every tool call will fail`);
58
+ return {
59
+ configError: bootstrap.message,
60
+ consumers: {},
61
+ pool: null,
62
+ tools: buildConfigErrorToolMap(
63
+ bootstrap.config,
64
+ bootstrap.message,
65
+ context,
66
+ dependencies.buildToolMap,
67
+ ),
68
+ };
69
+ }
70
+ const config = bootstrap.config;
47
71
 
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());
72
+ const pluginVersion = dependencies.resolveVersion();
73
+ const environment = await prepareBridgeEnvironment(
74
+ { configRoot: directory, lspDirectory: directory, config, pluginVersion, notify },
75
+ dependencies,
76
+ );
55
77
  const canonicalDirectory = location.project?.canonical ?? directory;
56
78
  const consumers = dependencies.toolConsumers(context);
79
+ // Only a rejected configuration keeps AFT out of a project; there is no
80
+ // config switch that turns it off.
81
+ const isProjectEnabled = createProjectAcceptance(directory, dependencies);
82
+ // getPool is only called on a version mismatch, after the pool exists.
57
83
  const pool = await dependencies.acquireBridge(canonicalDirectory, {
58
84
  harness: "opencode",
59
- binaryPath,
85
+ binaryPath: environment.binaryPath,
60
86
  poolOptions: {
61
87
  ...dependencies.resolvePoolOptions(config),
88
+ ...createSharedPoolOptions({
89
+ pluginVersion,
90
+ getPool: () => pool,
91
+ isProjectEnabled,
92
+ notifyForRoot: (_projectRoot, message) => notify(message),
93
+ dependencies,
94
+ }),
62
95
  ...consumers.bridgeOptions,
63
96
  },
64
- configOverrides,
97
+ configOverrides: environment.configOverrides,
65
98
  subcConnectionFile: config.subc?.connection_file,
66
99
  });
100
+ environment.attach(pool);
67
101
  const toolContext = {
68
102
  pool,
69
103
  client: context,
70
104
  config,
71
105
  hashlineEffective: openCodeHashlineEffective(config),
72
- storageDir,
73
- isProjectEnabled: (projectRoot) =>
74
- projectRoot === directory ? true : dependencies.loadConfig(projectRoot).enabled !== false,
106
+ storageDir: environment.storageDir,
107
+ isProjectEnabled,
108
+ };
109
+ const tools = dependencies.buildToolMap(
110
+ toolContext,
111
+ config,
112
+ unknownDisabledToolsReporter(notify),
113
+ );
114
+ const registeredTools = new Set(Object.keys(tools));
115
+ const { hashlineEditRegistered } = applyToolSurfaceOverrides(pool, config, registeredTools);
116
+ reportHashlineDowngrade(config, registeredTools, notify);
117
+ toolContext.hashlineEffective = hashlineEditRegistered;
118
+ // Keep this Location's live config keys current when a config file changes.
119
+ // The finalizer that releases the bridge also stops the watch.
120
+ const liveConfigReload = dependencies.startLiveConfigReload({
121
+ directory,
122
+ initialSources: bootstrap.sources ?? [],
123
+ initialSourceTexts: bootstrap.sourceTexts,
124
+ getConfig: () => toolContext.config,
125
+ setConfig: (next) => {
126
+ toolContext.config = next;
127
+ },
128
+ notify,
129
+ });
130
+ return { consumers, pool, tools, liveConfigReload };
131
+ }
132
+
133
+ /**
134
+ * Locations whose runtime is running in this process, by directory, so the
135
+ * start line can say when the host starts a second Location for a directory
136
+ * that already has one.
137
+ */
138
+ const runningByDirectory = new Map();
139
+ let startsInProcess = 0;
140
+
141
+ /**
142
+ * The start line names the entry, the Location and how many starts this
143
+ * process has seen. The V2 host calls the server effect once per Location and
144
+ * again when it reloads one, so two start lines on one host launch are two
145
+ * Locations or a reload; with the Location and the matching "stopped" line in
146
+ * the log, a reader can tell which instead of seeing the same line twice.
147
+ */
148
+ function logRuntimeStart(location) {
149
+ startsInProcess += 1;
150
+ const directory = location.directory;
151
+ const running = runningByDirectory.get(directory) ?? 0;
152
+ runningByDirectory.set(directory, running + 1);
153
+ const id = typeof location.id === "string" ? location.id : "(no id)";
154
+ const overlap =
155
+ running > 0
156
+ ? `; ${running} other Location(s) for this directory still running`
157
+ : "";
158
+ log(
159
+ `AFT V2 runtime starting (server entry, Location ${id} at ${directory}; start ${startsInProcess} in this process${overlap})`,
160
+ );
161
+ return () => {
162
+ const left = (runningByDirectory.get(directory) ?? 1) - 1;
163
+ if (left > 0) runningByDirectory.set(directory, left);
164
+ else runningByDirectory.delete(directory);
165
+ log(`AFT V2 runtime stopped (server entry, Location ${id} at ${directory})`);
75
166
  };
76
- const tools = dependencies.buildToolMap(toolContext, config);
77
- return { consumers, pool, tools };
78
167
  }
79
168
 
80
169
  export function makeServerEffect(overrides = {}) {
@@ -93,14 +182,24 @@ export function makeServerEffect(overrides = {}) {
93
182
  return Effect.void;
94
183
  }
95
184
 
96
- log("AFT V2 runtime starting");
97
-
98
185
  return Effect.gen(function* () {
186
+ // Logged when the program runs, so every start pairs with the stop its
187
+ // finalizer logs when the host disposes the Location.
188
+ const logRuntimeStop = logRuntimeStart(location);
189
+ yield* Effect.addFinalizer(() => Effect.sync(logRuntimeStop));
99
190
  const runtime = yield* Effect.promise(() => bootLocation(context, location, dependencies));
100
191
  if (!runtime) return;
101
192
 
193
+ if (runtime.configError !== undefined) {
194
+ const rpc = yield* dependencies.registerConfigErrorRpc(context, runtime.configError);
195
+ yield* Effect.addFinalizer(() => Effect.promise(() => rpc.dispose()));
196
+ yield* dependencies.registerTools(context, location, runtime.tools, runtime.consumers);
197
+ return;
198
+ }
199
+
102
200
  yield* Effect.addFinalizer(() =>
103
201
  Effect.promise(async () => {
202
+ runtime.liveConfigReload?.stop();
104
203
  runtime.consumers.dispose?.();
105
204
  await dependencies.releaseBridge(runtime.pool);
106
205
  }),
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
  /**
@@ -0,0 +1,100 @@
1
+ import { resolvePromptContext } from "./last-assistant-model.js";
2
+
3
+ export const VISION_HOST_TIMEOUT_MS = 1500;
4
+ type Model = { providerID: string; modelID: string };
5
+ type State = {
6
+ models: Map<string, Model>;
7
+ history: Map<string, Promise<Model | undefined>>;
8
+ capabilities: Map<string, Promise<boolean | undefined>>;
9
+ };
10
+ const states = new WeakMap<object, State>();
11
+ function stateFor(client: object): State {
12
+ let state = states.get(client);
13
+ if (!state) {
14
+ state = { models: new Map(), history: new Map(), capabilities: new Map() };
15
+ states.set(client, state);
16
+ }
17
+ return state;
18
+ }
19
+
20
+ /** Host hooks are authoritative; history is only a fallback before the first hook. */
21
+ export function rememberReadModel(client: object, sessionID: string, model: Model): void {
22
+ stateFor(client).models.set(sessionID, model);
23
+ }
24
+
25
+ async function bounded<T>(call: () => Promise<T>): Promise<T | undefined> {
26
+ let timer: ReturnType<typeof setTimeout> | undefined;
27
+ try {
28
+ return await Promise.race([
29
+ Promise.resolve().then(call),
30
+ new Promise<undefined>((resolve) => {
31
+ timer = setTimeout(() => resolve(undefined), VISION_HOST_TIMEOUT_MS);
32
+ }),
33
+ ]);
34
+ } catch {
35
+ return undefined;
36
+ } finally {
37
+ clearTimeout(timer);
38
+ }
39
+ }
40
+ function record(value: unknown): Record<string, unknown> | undefined {
41
+ return typeof value === "object" && value !== null && !Array.isArray(value)
42
+ ? (value as Record<string, unknown>)
43
+ : undefined;
44
+ }
45
+
46
+ export async function currentSessionVisionCapability(
47
+ client: object,
48
+ sessionID: string | undefined,
49
+ ): Promise<boolean | undefined> {
50
+ if (!sessionID) return undefined;
51
+ const state = stateFor(client);
52
+ let model = state.models.get(sessionID);
53
+ if (!model) {
54
+ let pending = state.history.get(sessionID);
55
+ if (!pending) {
56
+ pending = bounded(() => resolvePromptContext(client, sessionID)).then(
57
+ (context) => context?.model,
58
+ );
59
+ state.history.set(sessionID, pending);
60
+ void pending.finally(() => state.history.delete(sessionID));
61
+ }
62
+ const historical = await pending;
63
+ // A hook may have supplied a newer model while history was in flight.
64
+ model = state.models.get(sessionID) ?? historical;
65
+ }
66
+ if (!model) return undefined;
67
+ const currentModel = model;
68
+ const key = JSON.stringify([model.providerID, model.modelID]);
69
+ let capability = state.capabilities.get(key);
70
+ if (!capability) {
71
+ capability = (async () => {
72
+ const api = (client as { provider?: { list?: () => Promise<unknown> } }).provider;
73
+ if (!api?.list) return undefined;
74
+ const list = api.list.bind(api);
75
+ const listed = record(await bounded(list));
76
+ const catalog = record(listed?.data) ?? listed;
77
+ const providers = catalog?.all ?? catalog?.providers;
78
+ if (!Array.isArray(providers)) return undefined;
79
+ const provider = providers.map(record).find((entry) => entry?.id === currentModel.providerID);
80
+ const models = provider?.models;
81
+ const entry = record(
82
+ Array.isArray(models)
83
+ ? models.find((entry) => record(entry)?.id === currentModel.modelID)
84
+ : record(models)?.[currentModel.modelID],
85
+ );
86
+ const inputs = record(entry?.modalities)?.input;
87
+ return Array.isArray(inputs)
88
+ ? inputs.includes("image")
89
+ : typeof entry?.attachment === "boolean"
90
+ ? entry.attachment
91
+ : undefined;
92
+ })();
93
+ state.capabilities.set(key, capability);
94
+ // Do not retain transient failures or unknown catalog entries indefinitely.
95
+ void capability.then((value) => {
96
+ if (value === undefined) state.capabilities.delete(key);
97
+ });
98
+ }
99
+ return capability;
100
+ }
@@ -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) ? (