@cortexkit/aft-opencode 0.63.5 → 0.65.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 (52) hide show
  1. package/dist/bg-notifications.d.ts.map +1 -1
  2. package/dist/config-live-reload.d.ts +4 -28
  3. package/dist/config-live-reload.d.ts.map +1 -1
  4. package/dist/config.d.ts +11 -40
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/entry/server.js +1726 -674
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1726 -674
  9. package/dist/lsp-auto-install.d.ts.map +1 -1
  10. package/dist/shared/session-directory.d.ts +11 -4
  11. package/dist/shared/session-directory.d.ts.map +1 -1
  12. package/dist/shared/status.d.ts +2 -6
  13. package/dist/shared/status.d.ts.map +1 -1
  14. package/dist/subc-tool-schemas.d.ts +4 -3
  15. package/dist/subc-tool-schemas.d.ts.map +1 -1
  16. package/dist/tool-registration.d.ts +1 -1
  17. package/dist/tool-registration.d.ts.map +1 -1
  18. package/dist/tools/_shared.d.ts +11 -17
  19. package/dist/tools/_shared.d.ts.map +1 -1
  20. package/dist/tools/bash.d.ts.map +1 -1
  21. package/dist/tools/bash_watch.d.ts +6 -3
  22. package/dist/tools/bash_watch.d.ts.map +1 -1
  23. package/dist/tools/definitions/v2.d.ts +26 -1
  24. package/dist/tools/definitions/v2.d.ts.map +1 -1
  25. package/dist/tools/inspect.d.ts +0 -15
  26. package/dist/tools/inspect.d.ts.map +1 -1
  27. package/dist/tui/preferences.d.ts +1 -1
  28. package/dist/tui/shared-sidebar.d.ts +4 -0
  29. package/dist/tui/shared-sidebar.d.ts.map +1 -0
  30. package/dist/tui/v2-status.d.ts +3 -2
  31. package/dist/tui/v2-status.d.ts.map +1 -1
  32. package/dist/v2-session-lifecycle.d.ts.map +1 -1
  33. package/dist/workflow-hints.d.ts +1 -5
  34. package/dist/workflow-hints.d.ts.map +1 -1
  35. package/package.json +14 -12
  36. package/src/shared/session-directory.ts +90 -17
  37. package/src/shared/status.ts +6 -26
  38. package/src/tui/index.tsx +8 -28
  39. package/src/tui/preferences.ts +1 -1
  40. package/src/tui/shared-sidebar.ts +14 -0
  41. package/src/tui/sidebar-view.tsx +257 -283
  42. package/src/tui/v2-status.ts +5 -14
  43. package/src/tui-compiled/index.tsx +6 -44
  44. package/src/tui-compiled/preferences.ts +1 -1
  45. package/src/tui-compiled/shared-sidebar-host.tsx +816 -0
  46. package/src/tui-compiled/shared-sidebar-local.tsx +816 -0
  47. package/src/tui-compiled/shared-sidebar.LICENSE +21 -0
  48. package/src/tui-compiled/shared-sidebar.ts +14 -0
  49. package/src/tui-compiled/sidebar-view.tsx +185 -261
  50. package/src/tui-compiled/sidebar.tsx +1 -0
  51. package/src/tui-compiled/v2-status.ts +5 -14
  52. package/src/tui-compiled/v2.tsx +1 -0
@@ -12,8 +12,8 @@
12
12
  *
13
13
  * The session itself stores the correct directory in OpenCode's SQLite
14
14
  * (`Session.directory` in the SDK). This helper looks it up once per
15
- * session and caches the result — sessions don't change directory
16
- * across their lifetime, so the cache never needs invalidation.
15
+ * session and caches the result. A session.moved event invalidates the
16
+ * cached root because OpenCode 2 sessions can change Location.
17
17
  *
18
18
  * The bug should also be fixed upstream in OpenCode's
19
19
  * `packages/opencode/src/session/registry.ts`. Until then this
@@ -50,11 +50,16 @@ interface SessionInfo {
50
50
  * same test for its own session lookup.
51
51
  */
52
52
  interface V2SessionInfo {
53
- location?: { directory?: unknown };
53
+ location?: { directory?: unknown; workspaceID?: string };
54
+ }
55
+
56
+ interface SessionLocation {
57
+ directory: string;
58
+ workspaceID?: string;
54
59
  }
55
60
 
56
61
  interface V2PluginContextShape {
57
- location: { directory: string };
62
+ location: SessionLocation;
58
63
  session: {
59
64
  get(input: { sessionID: string }): Effect.Effect<V2SessionInfo | undefined, unknown>;
60
65
  };
@@ -92,6 +97,8 @@ interface OpenCodeClientShape {
92
97
  interface CacheEntry {
93
98
  /** Resolved directory, or `null` while a failed lookup is cooling down. */
94
99
  directory: string | null;
100
+ /** V2 Location refs distinguish workspaces that share a directory. */
101
+ location?: SessionLocation;
95
102
  /** When the answer was recorded; failed lookups become retryable after 30 seconds. */
96
103
  recordedAt: number;
97
104
  }
@@ -104,8 +111,9 @@ const lookupGenerations = new Map<string, object>();
104
111
  /**
105
112
  * Resolve the project directory the session was created with from the SDK's
106
113
  * session object. Returns the SDK-reported directory, or `null` when the
107
- * lookup is unavailable/fails — callers should fall back to the runtime's
108
- * directory in that case.
114
+ * lookup is unavailable/fails. Only session-scoped tool calls may fall back
115
+ * to their runtime directory; broadcast events must not open a route without
116
+ * a resolved session directory.
109
117
  *
110
118
  * This function is best-effort: any error (missing `client.session.get`,
111
119
  * network failure, malformed response) is logged and recorded as a
@@ -139,6 +147,55 @@ export async function getSessionDirectory(
139
147
  return lookup;
140
148
  }
141
149
 
150
+ /** Session events may open routes only in the owning V2 Location. */
151
+ export async function getSessionDirectoryForEvent(
152
+ client: unknown,
153
+ sessionId: string,
154
+ instanceDirectory: string,
155
+ ): Promise<string | null> {
156
+ const directory = await getSessionDirectory(client, sessionId, instanceDirectory);
157
+ if (
158
+ isV2PluginContext(client) &&
159
+ (directory !== client.location.directory ||
160
+ cache.get(sessionId)?.location?.workspaceID !== client.location.workspaceID)
161
+ )
162
+ return null;
163
+ return directory;
164
+ }
165
+
166
+ /** Deleted records cannot be queried: prefer the event's Location, then a previously resolved ref. */
167
+ export function getSessionDirectoryForDeletedEvent(
168
+ client: unknown,
169
+ sessionId: string,
170
+ eventLocation: unknown,
171
+ ): string | null {
172
+ if (!sessionId || !isV2PluginContext(client)) return null;
173
+ let location: SessionLocation | undefined;
174
+ if (eventLocation === undefined) {
175
+ location = cache.get(sessionId)?.location;
176
+ } else {
177
+ if (!eventLocation || typeof eventLocation !== "object") return null;
178
+ const ref = eventLocation as { directory?: unknown; workspaceID?: unknown };
179
+ if (
180
+ typeof ref.directory !== "string" ||
181
+ !ref.directory ||
182
+ (ref.workspaceID !== undefined && typeof ref.workspaceID !== "string")
183
+ )
184
+ return null;
185
+ location = { directory: ref.directory, workspaceID: ref.workspaceID };
186
+ // The cleanup's bridge call also consults this cache. Preserve the event's
187
+ // ownership proof there instead of asking the host for an already-deleted record.
188
+ setCache(sessionId, location.directory, location);
189
+ }
190
+ if (
191
+ !location ||
192
+ location.directory !== client.location.directory ||
193
+ location.workspaceID !== client.location.workspaceID
194
+ )
195
+ return null;
196
+ return location.directory;
197
+ }
198
+
142
199
  /** A late success may repair its provisional answer, but never overwrite a newer lookup. */
143
200
  async function boundedLookup(
144
201
  client: unknown,
@@ -152,6 +209,7 @@ async function boundedLookup(
152
209
  if (oldest !== undefined) lookupGenerations.delete(oldest);
153
210
  }
154
211
  let timer: ReturnType<typeof setTimeout> | undefined;
212
+ let location: CacheEntry["location"];
155
213
  try {
156
214
  const timeout = new Promise<null>((resolve) => {
157
215
  timer = setTimeout(() => {
@@ -163,10 +221,12 @@ async function boundedLookup(
163
221
  }, LOOKUP_TIMEOUT_MS);
164
222
  });
165
223
  const directory = await Promise.race([
166
- lookupSessionDirectory(client, sessionId, fallbackDirectory)
224
+ lookupSessionDirectory(client, sessionId, fallbackDirectory, (ref) => {
225
+ location = ref;
226
+ })
167
227
  .then((directory) => {
168
228
  if (lookupGenerations.get(sessionId) === generation && directory !== null) {
169
- setCache(sessionId, directory);
229
+ setCache(sessionId, directory, location);
170
230
  }
171
231
  return directory;
172
232
  })
@@ -175,7 +235,7 @@ async function boundedLookup(
175
235
  }),
176
236
  timeout,
177
237
  ]);
178
- setCache(sessionId, directory);
238
+ setCache(sessionId, directory, location);
179
239
  return directory;
180
240
  } finally {
181
241
  clearTimeout(timer);
@@ -186,8 +246,9 @@ async function lookupSessionDirectory(
186
246
  client: unknown,
187
247
  sessionId: string,
188
248
  fallbackDirectory: string,
249
+ onLocation: (location: NonNullable<CacheEntry["location"]>) => void,
189
250
  ): Promise<string | null> {
190
- if (isV2PluginContext(client)) return lookupV2SessionDirectory(client, sessionId);
251
+ if (isV2PluginContext(client)) return lookupV2SessionDirectory(client, sessionId, onLocation);
191
252
 
192
253
  const c = client as OpenCodeClientShape;
193
254
  const sessionApi = c?.session;
@@ -236,6 +297,7 @@ async function lookupSessionDirectory(
236
297
  async function lookupV2SessionDirectory(
237
298
  context: V2PluginContextShape,
238
299
  sessionId: string,
300
+ onLocation: (location: NonNullable<CacheEntry["location"]>) => void,
239
301
  ): Promise<string | null> {
240
302
  let dir: string | null = null;
241
303
  let failure: string | undefined;
@@ -247,8 +309,10 @@ async function lookupV2SessionDirectory(
247
309
  ),
248
310
  );
249
311
  const directory = session?.location?.directory;
250
- if (typeof directory === "string" && directory.length > 0) dir = directory;
251
- else failure = "the session record has no location.directory";
312
+ if (typeof directory === "string" && directory.length > 0) {
313
+ dir = directory;
314
+ onLocation({ directory, workspaceID: session?.location?.workspaceID });
315
+ } else failure = "the session record has no location.directory";
252
316
  } catch (err) {
253
317
  failure = err instanceof Error ? err.message || err.name : String(err);
254
318
  }
@@ -261,9 +325,13 @@ async function lookupV2SessionDirectory(
261
325
  return dir;
262
326
  }
263
327
 
264
- function setCache(sessionId: string, directory: string | null): void {
328
+ function setCache(
329
+ sessionId: string,
330
+ directory: string | null,
331
+ location?: CacheEntry["location"],
332
+ ): void {
265
333
  if (cache.has(sessionId)) cache.delete(sessionId);
266
- cache.set(sessionId, { directory, recordedAt: Date.now() });
334
+ cache.set(sessionId, { directory, location, recordedAt: Date.now() });
267
335
  if (cache.size > CACHE_MAX_ENTRIES) {
268
336
  const oldest = cache.keys().next().value;
269
337
  if (oldest !== undefined) cache.delete(oldest);
@@ -385,6 +453,13 @@ async function boundedVerification(client: unknown, sessionId: string): Promise<
385
453
  return dir;
386
454
  }
387
455
 
456
+ /** Forget directory snapshots when the host moves or removes a session. */
457
+ export function clearSessionDirectory(sessionId: string): void {
458
+ cache.delete(sessionId);
459
+ lookupGenerations.delete(sessionId);
460
+ verifyCache.delete(sessionId);
461
+ }
462
+
388
463
  /** Release transport routes on deletion without querying a session the host has already removed. */
389
464
  export async function closeDeletedSessionRoutes(
390
465
  pool: AftTransportPool,
@@ -395,9 +470,7 @@ export async function closeDeletedSessionRoutes(
395
470
  const roots = new Set(pool.activeBridges().map((bridge) => bridge.getCwd()));
396
471
  roots.add(getSessionDirectoryCached(sessionId) ?? fallbackDirectory);
397
472
  await Promise.allSettled([...roots].map((root) => pool.closeSession(root, sessionId)));
398
- cache.delete(sessionId);
399
- lookupGenerations.delete(sessionId);
400
- verifyCache.delete(sessionId);
473
+ clearSessionDirectory(sessionId);
401
474
  }
402
475
 
403
476
  /** Test-only: clear the cache between unit tests. */
@@ -59,11 +59,7 @@ export interface StatusBar {
59
59
  disabled_categories?: string[];
60
60
  errors?: number;
61
61
  warnings?: number;
62
- dead_code?: number;
63
- unused_exports?: number;
64
- duplicates?: number;
65
62
  todos?: number;
66
- tier2_stale?: boolean;
67
63
  /**
68
64
  * `no_language_server` means no server is running. `producer_missing`
69
65
  * masks both counts when an expected project producer has failed, even if
@@ -147,12 +143,12 @@ export interface AftStatusSnapshot {
147
143
  };
148
144
  storage_dir: string | null;
149
145
  /** Total checkpoints across all sessions sharing this bridge. */
150
- checkpoints_total: number;
146
+ checkpoints_total: number | null;
151
147
  /** Current session's own slice of undo/checkpoint state. */
152
148
  session: {
153
149
  id: string;
154
150
  tracked_files: number;
155
- checkpoints: number;
151
+ checkpoints: number | null;
156
152
  };
157
153
  /** Compression aggregate passthrough; rendering is added separately. */
158
154
  compression?: StatusCompression;
@@ -233,9 +229,6 @@ function readStatusBar(value: unknown): StatusBar | undefined {
233
229
  : [];
234
230
  const errors = readOptionalNumber(bar.errors);
235
231
  const warnings = readOptionalNumber(bar.warnings);
236
- const deadCode = readOptionalNumber(bar.dead_code);
237
- const unusedExports = readOptionalNumber(bar.unused_exports);
238
- const duplicates = readOptionalNumber(bar.duplicates);
239
232
  const todos = readOptionalNumber(bar.todos);
240
233
  const noLanguageServer = bar.diagnostics === "no_language_server";
241
234
  const producerMissing = bar.diagnostics === "producer_missing";
@@ -257,9 +250,6 @@ function readStatusBar(value: unknown): StatusBar | undefined {
257
250
  if (
258
251
  errors === null &&
259
252
  warnings === null &&
260
- deadCode === null &&
261
- unusedExports === null &&
262
- duplicates === null &&
263
253
  todos === null &&
264
254
  !noLanguageServer &&
265
255
  !producerMissing &&
@@ -271,11 +261,7 @@ function readStatusBar(value: unknown): StatusBar | undefined {
271
261
  ...(disabled.length > 0 ? { disabled_categories: disabled } : {}),
272
262
  ...(errors !== null ? { errors } : {}),
273
263
  ...(warnings !== null ? { warnings } : {}),
274
- ...(deadCode !== null ? { dead_code: deadCode } : {}),
275
- ...(unusedExports !== null ? { unused_exports: unusedExports } : {}),
276
- ...(duplicates !== null ? { duplicates } : {}),
277
264
  ...(todos !== null ? { todos } : {}),
278
- ...(bar.tier2_stale === true ? { tier2_stale: true } : {}),
279
265
  ...(noLanguageServer ? { diagnostics: "no_language_server" as const } : {}),
280
266
  ...(producerMissing
281
267
  ? { diagnostics: "producer_missing" as const, diagnostics_gaps: diagnosticsGaps }
@@ -417,11 +403,11 @@ export function coerceAftStatus(response: Record<string, unknown>): AftStatusSna
417
403
  warm_entries: readNumber(symbolCache.warm_entries),
418
404
  },
419
405
  storage_dir: readNullableString(response.storage_dir),
420
- checkpoints_total: readNumber(response.checkpoints_total),
406
+ checkpoints_total: readOptionalNumber(response.checkpoints_total),
421
407
  session: {
422
408
  id: readString(session.id, "__default__"),
423
409
  tracked_files: readNumber(session.tracked_files),
424
- checkpoints: readNumber(session.checkpoints),
410
+ checkpoints: readOptionalNumber(session.checkpoints),
425
411
  },
426
412
  compression: readCompression(response.compression),
427
413
  status_bar: readStatusBar(response.status_bar),
@@ -501,13 +487,10 @@ export function formatStatusDialogMessage(status: AftStatusSnapshot): string {
501
487
  const rows = [
502
488
  ["errors", sb.errors],
503
489
  ["warnings", sb.warnings],
504
- ["dead code", sb.dead_code],
505
- ["unused exports", sb.unused_exports],
506
- ["duplicates", sb.duplicates],
507
490
  ["todos", sb.todos],
508
491
  ].filter((row): row is [string, number] => typeof row[1] === "number");
509
492
  if (rows.length > 0) {
510
- lines.push("", `Code Health${sb.tier2_stale ? " (~ stale)" : ""}`);
493
+ lines.push("", "Code Health");
511
494
  for (const [label, value] of rows) lines.push(`- ${label}: ${formatCount(value)}`);
512
495
  }
513
496
  }
@@ -660,13 +643,10 @@ export function formatStatusMarkdown(status: AftStatusSnapshot): string {
660
643
  const rows = [
661
644
  ["Errors", sb.errors],
662
645
  ["Warnings", sb.warnings],
663
- ["Dead code", sb.dead_code],
664
- ["Unused exports", sb.unused_exports],
665
- ["Duplicates", sb.duplicates],
666
646
  ["TODOs", sb.todos],
667
647
  ].filter((row): row is [string, number] => typeof row[1] === "number");
668
648
  if (rows.length > 0) {
669
- lines.push("", `### Code Health${sb.tier2_stale ? " (~ stale)" : ""}`);
649
+ lines.push("", "### Code Health");
670
650
  for (const [label, value] of rows) lines.push(`- **${label}:** ${formatCount(value)}`);
671
651
  }
672
652
  }
package/src/tui/index.tsx CHANGED
@@ -155,7 +155,7 @@ function statusAcceptGate(directory: string): (result: unknown) => boolean {
155
155
  };
156
156
  }
157
157
 
158
- const StatusDialog = (props: StatusDialogProps) => {
158
+ export const StatusDialog = (props: StatusDialogProps) => {
159
159
  const theme = createMemo(() => (props.api as any).theme.current as TuiThemeCurrent);
160
160
  const t = () => theme();
161
161
 
@@ -426,11 +426,15 @@ const StatusDialog = (props: StatusDialogProps) => {
426
426
  </text>
427
427
  </box>
428
428
  <R theme={t()} label="Tracked files" value={String(status()!.session.tracked_files)} />
429
- <R theme={t()} label="Checkpoints" value={String(status()!.session.checkpoints)} />
429
+ <R
430
+ theme={t()}
431
+ label="Checkpoints"
432
+ value={status()!.session.checkpoints?.toString() ?? "—"}
433
+ />
430
434
  <R
431
435
  theme={t()}
432
436
  label="All-session checkpoints"
433
- value={String(status()!.checkpoints_total)}
437
+ value={status()!.checkpoints_total?.toString() ?? "—"}
434
438
  tone="muted"
435
439
  />
436
440
  </box>
@@ -496,7 +500,7 @@ const StatusDialog = (props: StatusDialogProps) => {
496
500
  {statusBar() ? (
497
501
  <box flexDirection="column" width="100%" marginTop={1}>
498
502
  <text fg={t().text}>
499
- <b>{statusBar()!.tier2_stale ? "Code Health ~" : "Code Health"}</b>
503
+ <b>Code Health</b>
500
504
  </text>
501
505
  {(statusBar()!.disabled_categories ?? []).map((key) => (
502
506
  <R theme={t()} label={key.replaceAll("_", " ")} value="○ off" tone="muted" />
@@ -517,30 +521,6 @@ const StatusDialog = (props: StatusDialogProps) => {
517
521
  tone={statusBar()!.warnings! > 0 ? "warn" : "muted"}
518
522
  />
519
523
  ) : null}
520
- {statusBar()!.dead_code !== undefined ? (
521
- <R
522
- theme={t()}
523
- label="Dead Code"
524
- value={formatCountShort(statusBar()!.dead_code)}
525
- tone="muted"
526
- />
527
- ) : null}
528
- {statusBar()!.unused_exports !== undefined ? (
529
- <R
530
- theme={t()}
531
- label="Unused Exports"
532
- value={formatCountShort(statusBar()!.unused_exports)}
533
- tone="muted"
534
- />
535
- ) : null}
536
- {statusBar()!.duplicates !== undefined ? (
537
- <R
538
- theme={t()}
539
- label="Duplicates"
540
- value={formatCountShort(statusBar()!.duplicates)}
541
- tone="muted"
542
- />
543
- ) : null}
544
524
  {statusBar()!.todos !== undefined ? (
545
525
  <R
546
526
  theme={t()}
@@ -40,7 +40,7 @@ export interface AftTuiPrefs {
40
40
  rememberCollapsed: boolean;
41
41
  collapsed: boolean | null;
42
42
  /**
43
- * Whether OpenCode 2 shows AFT's health line (`AFT E0 W0 | D0 U0 C0 | T0`)
43
+ * Whether OpenCode 2 shows AFT's health line (`AFT E0 W0 | T0`)
44
44
  * in the prompt footer. Off hides it entirely; the sidebar and the status
45
45
  * dialog are unaffected.
46
46
  */
@@ -0,0 +1,14 @@
1
+ import type * as SharedSidebar from "@cortexkit/opencode-sidebar";
2
+
3
+ // Raw-source hosts use their ordinary runtime; published hosts use the virtual
4
+ // runtime module selected by build-tui.ts instead of this loader.
5
+ const generatedModule = "../tui-compiled/shared-sidebar-local.tsx";
6
+ const sidebar: typeof SharedSidebar = await import(generatedModule).catch((cause: unknown) => {
7
+ throw new Error(
8
+ "AFT's generated shared sidebar could not be loaded. Run `bun run --cwd packages/opencode-plugin build:tui` before loading the TUI from source.",
9
+ { cause },
10
+ );
11
+ });
12
+
13
+ export const SidebarFrame = sidebar.SidebarFrame;
14
+ export type { SidebarTheme } from "@cortexkit/opencode-sidebar";