@cortexkit/aft-opencode 0.57.1 → 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 (55) 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 +9984 -7887
  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 +9802 -7770
  15. package/dist/logger.d.ts +6 -0
  16. package/dist/logger.d.ts.map +1 -1
  17. package/dist/lsp-auto-install.d.ts.map +1 -1
  18. package/dist/rpc/register.d.ts +7 -1
  19. package/dist/rpc/register.d.ts.map +1 -1
  20. package/dist/shared/status.d.ts +53 -0
  21. package/dist/shared/status.d.ts.map +1 -1
  22. package/dist/shared/subagent-detect.d.ts.map +1 -1
  23. package/dist/shutdown-hooks.d.ts +3 -9
  24. package/dist/shutdown-hooks.d.ts.map +1 -1
  25. package/dist/subc-tool-schemas.d.ts +9 -1
  26. package/dist/subc-tool-schemas.d.ts.map +1 -1
  27. package/dist/tool-registration.d.ts +19 -12
  28. package/dist/tool-registration.d.ts.map +1 -1
  29. package/dist/tools/ast.d.ts.map +1 -1
  30. package/dist/tools/bash.d.ts +1 -1
  31. package/dist/tools/bash.d.ts.map +1 -1
  32. package/dist/tools/bash_watch.d.ts.map +1 -1
  33. package/dist/tools/definitions/v2.d.ts.map +1 -1
  34. package/dist/tools/hoisted.d.ts +9 -12
  35. package/dist/tools/hoisted.d.ts.map +1 -1
  36. package/dist/tools/inspect.d.ts +5 -7
  37. package/dist/tools/inspect.d.ts.map +1 -1
  38. package/dist/tools/permissions.d.ts.map +1 -1
  39. package/dist/tools/reading.d.ts.map +1 -1
  40. package/dist/tools/search.d.ts.map +1 -1
  41. package/dist/tui/v2-status.d.ts +8 -1
  42. package/dist/tui/v2-status.d.ts.map +1 -1
  43. package/dist/workflow-hints.d.ts +1 -7
  44. package/dist/workflow-hints.d.ts.map +1 -1
  45. package/package.json +8 -8
  46. package/src/entry/server-runtime.mjs +117 -33
  47. package/src/logger.ts +18 -6
  48. package/src/shared/status.ts +92 -4
  49. package/src/shared/subagent-detect.ts +56 -1
  50. package/src/tui/index.tsx +4 -10
  51. package/src/tui/sidebar-view.tsx +36 -15
  52. package/src/tui/v2-status.ts +34 -6
  53. package/src/tui-compiled/index.tsx +3 -3
  54. package/src/tui-compiled/sidebar-view.tsx +168 -126
  55. package/src/tui-compiled/v2-status.ts +34 -6
@@ -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
  /**
@@ -110,6 +109,19 @@ export function getLogFilePath(): string {
110
109
  return logFile;
111
110
  }
112
111
 
112
+ /**
113
+ * Write buffered log lines now instead of on the next flush tick. For lines
114
+ * logged while the host may be about to exit, which would drop anything still
115
+ * buffered.
116
+ */
117
+ export function flushLog(): void {
118
+ if (flushTimer) {
119
+ clearTimeout(flushTimer);
120
+ flushTimer = null;
121
+ }
122
+ flush();
123
+ }
124
+
113
125
  /**
114
126
  * Adapter that exposes this logger as a {@link import("@cortexkit/aft-bridge").Logger}
115
127
  * for the shared bridge package. The bridge package never knows about this log
@@ -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;
@@ -109,6 +144,14 @@ export interface AftStatusSnapshot {
109
144
  compression?: StatusCompression;
110
145
  /** Human health counts. Each unproven category stays absent rather than zero. */
111
146
  status_bar?: StatusBar;
147
+ /**
148
+ * Per-category health values from the same snapshot. The bridge sends
149
+ * `status_bar` only once every category has a value; this field carries the
150
+ * categories that do, so a renderer can show them while one producer (for
151
+ * example dead-code without a callgraph) is unavailable. Absent when the
152
+ * bridge predates the field or no category has a value yet.
153
+ */
154
+ status_bar_values?: StatusBar;
112
155
  /**
113
156
  * Human-readable explanation for a synthetic snapshot (e.g.
114
157
  * `cache_role === "not_initialized"`). When the plugin returns a placeholder
@@ -117,6 +160,12 @@ export interface AftStatusSnapshot {
117
160
  * Empty string when the snapshot is real bridge data.
118
161
  */
119
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;
120
169
  }
121
170
 
122
171
  function asRecord(value: unknown): Record<string, unknown> {
@@ -171,13 +220,15 @@ function readStatusBar(value: unknown): StatusBar | undefined {
171
220
  const unusedExports = readOptionalNumber(bar.unused_exports);
172
221
  const duplicates = readOptionalNumber(bar.duplicates);
173
222
  const todos = readOptionalNumber(bar.todos);
223
+ const noLanguageServer = bar.diagnostics === "no_language_server";
174
224
  if (
175
225
  errors === null &&
176
226
  warnings === null &&
177
227
  deadCode === null &&
178
228
  unusedExports === null &&
179
229
  duplicates === null &&
180
- todos === null
230
+ todos === null &&
231
+ !noLanguageServer
181
232
  ) {
182
233
  return undefined;
183
234
  }
@@ -189,6 +240,7 @@ function readStatusBar(value: unknown): StatusBar | undefined {
189
240
  ...(duplicates !== null ? { duplicates } : {}),
190
241
  ...(todos !== null ? { todos } : {}),
191
242
  ...(bar.tier2_stale === true ? { tier2_stale: true } : {}),
243
+ ...(noLanguageServer ? { diagnostics: "no_language_server" as const } : {}),
192
244
  };
193
245
  }
194
246
 
@@ -289,6 +341,8 @@ export function coerceAftStatus(response: Record<string, unknown>): AftStatusSna
289
341
  entries: readOptionalNumber(semanticIndex.entries),
290
342
  dimension: readOptionalNumber(semanticIndex.dimension),
291
343
  error: readNullableString(semanticIndex.error),
344
+ reason: readNullableString(semanticIndex.reason),
345
+ backend_url: readNullableString(semanticIndex.backend_url),
292
346
  },
293
347
  disk: {
294
348
  storage_dir: readNullableString(disk.storage_dir),
@@ -314,7 +368,11 @@ export function coerceAftStatus(response: Record<string, unknown>): AftStatusSna
314
368
  },
315
369
  compression: readCompression(response.compression),
316
370
  status_bar: readStatusBar(response.status_bar),
371
+ status_bar_values: readStatusBar(response.status_bar_values),
317
372
  message: readString(response.message, ""),
373
+ ...(typeof response.config_error === "string" && response.config_error.length > 0
374
+ ? { config_error: response.config_error }
375
+ : {}),
318
376
  };
319
377
  }
320
378
 
@@ -347,7 +405,7 @@ export function formatStatusDialogMessage(status: AftStatusSnapshot): string {
347
405
  `- trigrams: ${formatCount(status.search_index.trigrams)}`,
348
406
  "",
349
407
  "Semantic index",
350
- `- status: ${formatSemanticIndexStatus(status.semantic_index.status, status.semantic_index.stage, status.semantic_index.error)}`,
408
+ `- status: ${formatSemanticIndexLabel(status.semantic_index)}`,
351
409
  );
352
410
  const refreshing = formatSemanticRefreshing(status.semantic_index.refreshing_count);
353
411
  if (refreshing) {
@@ -422,9 +480,39 @@ export function formatStatusDialogMessage(status: AftStatusSnapshot): string {
422
480
  return lines.join("\n");
423
481
  }
424
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
+
425
512
  function appendDegradedStatus(lines: string[], status: AftStatusSnapshot, markdown: boolean): void {
426
513
  if (!status.degraded || status.degraded_reasons.length === 0) return;
427
- lines.push("", markdown ? "### Degraded mode" : "Degraded mode");
514
+ const { heading } = degradedHeaderMarker(status.degraded_reasons);
515
+ lines.push("", markdown ? `### ${heading}` : heading);
428
516
  for (const reason of status.degraded_reasons) {
429
517
  const detail =
430
518
  reason === "home_root"
@@ -458,7 +546,7 @@ export function formatStatusMarkdown(status: AftStatusSnapshot): string {
458
546
  `- **Trigrams:** ${formatCount(status.search_index.trigrams)}`,
459
547
  "",
460
548
  "### Semantic index",
461
- `- **Status:** \`${formatSemanticIndexStatus(status.semantic_index.status, status.semantic_index.stage, status.semantic_index.error)}\``,
549
+ `- **Status:** \`${formatSemanticIndexLabel(status.semantic_index)}\``,
462
550
  );
463
551
  const refreshing = formatSemanticRefreshing(status.semantic_index.refreshing_count);
464
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