@cortexkit/aft-opencode 0.39.3 → 0.40.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 (42) hide show
  1. package/dist/config.d.ts +12 -1
  2. package/dist/config.d.ts.map +1 -1
  3. package/dist/configure-warnings.d.ts +3 -1
  4. package/dist/configure-warnings.d.ts.map +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +1434 -1272
  7. package/dist/lsp-cache.d.ts +3 -3
  8. package/dist/lsp-github-install.d.ts +5 -5
  9. package/dist/lsp-github-probe.d.ts +1 -1
  10. package/dist/notifications.d.ts +1 -1
  11. package/dist/notifications.d.ts.map +1 -1
  12. package/dist/patch-parser.d.ts.map +1 -1
  13. package/dist/shared/ignored-message.d.ts +19 -0
  14. package/dist/shared/ignored-message.d.ts.map +1 -0
  15. package/dist/shared/rpc-notifications.d.ts +45 -0
  16. package/dist/shared/rpc-notifications.d.ts.map +1 -0
  17. package/dist/shared/rpc-utils.d.ts +10 -3
  18. package/dist/shared/rpc-utils.d.ts.map +1 -1
  19. package/dist/subc-tool-schemas.d.ts +20 -0
  20. package/dist/subc-tool-schemas.d.ts.map +1 -0
  21. package/dist/tools/_shared.d.ts.map +1 -1
  22. package/dist/tools/ast.d.ts.map +1 -1
  23. package/dist/tools/bash.d.ts.map +1 -1
  24. package/dist/tools/bash_watch.d.ts +1 -1
  25. package/dist/tools/bash_watch.d.ts.map +1 -1
  26. package/dist/tools/hoisted.d.ts.map +1 -1
  27. package/dist/tools/inspect.d.ts.map +1 -1
  28. package/dist/tools/navigation.d.ts.map +1 -1
  29. package/dist/tools/permissions.d.ts +2 -1
  30. package/dist/tools/permissions.d.ts.map +1 -1
  31. package/dist/tools/reading.d.ts.map +1 -1
  32. package/dist/tools/refactoring.d.ts.map +1 -1
  33. package/dist/tools/search.d.ts.map +1 -1
  34. package/dist/tools/semantic.d.ts.map +1 -1
  35. package/dist/tui.js +1351 -81
  36. package/dist/workflow-hints.d.ts.map +1 -1
  37. package/package.json +11 -10
  38. package/src/shared/ignored-message.ts +61 -0
  39. package/src/shared/rpc-notifications.ts +121 -0
  40. package/src/shared/rpc-utils.ts +12 -7
  41. package/src/tui/index.tsx +118 -38
  42. package/src/tui/sidebar.tsx +12 -6
@@ -1 +1 @@
1
- {"version":3,"file":"workflow-hints.d.ts","sourceRoot":"","sources":["../src/workflow-hints.ts"],"names":[],"mappings":"AAUA,OAAO,EAAE,KAAK,SAAS,EAAqB,MAAM,aAAa,CAAC;AAEhE,MAAM,WAAW,iBAAiB;IAChC,oEAAoE;IACpE,WAAW,EAAE,SAAS,GAAG,aAAa,GAAG,KAAK,CAAC;IAC/C,4EAA4E;IAC5E,aAAa,EAAE,OAAO,CAAC;IACvB,mEAAmE;IACnE,eAAe,EAAE,OAAO,CAAC;IACzB,2DAA2D;IAC3D,qBAAqB,EAAE,OAAO,CAAC;IAC/B,sCAAsC;IACtC,sBAAsB,EAAE,OAAO,CAAC;IAChC,4DAA4D;IAC5D,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;CAC5B;AAID;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,iBAAiB,GAAG,MAAM,GAAG,IAAI,CAkIzE;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,SAAS,EAAE,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,MAAM,GAAG,IAAI,CASjG"}
1
+ {"version":3,"file":"workflow-hints.d.ts","sourceRoot":"","sources":["../src/workflow-hints.ts"],"names":[],"mappings":"AAUA,OAAO,EAAE,KAAK,SAAS,EAAqB,MAAM,aAAa,CAAC;AAEhE,MAAM,WAAW,iBAAiB;IAChC,oEAAoE;IACpE,WAAW,EAAE,SAAS,GAAG,aAAa,GAAG,KAAK,CAAC;IAC/C,4EAA4E;IAC5E,aAAa,EAAE,OAAO,CAAC;IACvB,mEAAmE;IACnE,eAAe,EAAE,OAAO,CAAC;IACzB,2DAA2D;IAC3D,qBAAqB,EAAE,OAAO,CAAC;IAC/B,sCAAsC;IACtC,sBAAsB,EAAE,OAAO,CAAC;IAChC,4DAA4D;IAC5D,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;CAC5B;AAID;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,iBAAiB,GAAG,MAAM,GAAG,IAAI,CAgIzE;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,SAAS,EAAE,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,MAAM,GAAG,IAAI,CASjG"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/aft-opencode",
3
- "version": "0.39.3",
3
+ "version": "0.40.0",
4
4
  "type": "module",
5
5
  "description": "OpenCode plugin for Agent File Tools (AFT) — tree-sitter and lsp powered code analysis",
6
6
  "main": "dist/index.js",
@@ -25,28 +25,29 @@
25
25
  "test:e2e": "bun test src/__tests__/e2e/",
26
26
  "lint": "biome check src",
27
27
  "schema": "bun scripts/build-schema.ts",
28
+ "build:tool-schemas": "bun run scripts/build-tool-schemas.ts",
28
29
  "prepublishOnly": "bun run build",
29
30
  "test:unit": "bun test src/__tests__ --path-ignore-patterns 'src/__tests__/e2e/**'"
30
31
  },
31
32
  "dependencies": {
32
33
  "@clack/prompts": "^1.2.0",
33
- "@cortexkit/aft-bridge": "0.39.3",
34
+ "@cortexkit/aft-bridge": "0.40.0",
34
35
  "@xterm/headless": "^5.5.0",
35
36
  "comment-json": "^4.6.2",
36
37
  "undici": "^7.25.0",
37
38
  "zod": "^4.1.8"
38
39
  },
39
40
  "optionalDependencies": {
40
- "@cortexkit/aft-darwin-arm64": "0.39.3",
41
- "@cortexkit/aft-darwin-x64": "0.39.3",
42
- "@cortexkit/aft-linux-arm64": "0.39.3",
43
- "@cortexkit/aft-linux-x64": "0.39.3",
44
- "@cortexkit/aft-win32-arm64": "0.39.3",
45
- "@cortexkit/aft-win32-x64": "0.39.3"
41
+ "@cortexkit/aft-darwin-arm64": "0.40.0",
42
+ "@cortexkit/aft-darwin-x64": "0.40.0",
43
+ "@cortexkit/aft-linux-arm64": "0.40.0",
44
+ "@cortexkit/aft-linux-x64": "0.40.0",
45
+ "@cortexkit/aft-win32-arm64": "0.40.0",
46
+ "@cortexkit/aft-win32-x64": "0.40.0"
46
47
  },
47
48
  "devDependencies": {
48
- "@opencode-ai/plugin": "^1.17.7",
49
- "@opencode-ai/sdk": "^1.17.7",
49
+ "@opencode-ai/plugin": "^1.17.9",
50
+ "@opencode-ai/sdk": "^1.17.9",
50
51
  "@types/node": "^22.0.0",
51
52
  "typescript": "^5.8.0"
52
53
  },
@@ -0,0 +1,61 @@
1
+ import { resolvePromptContext } from "./last-assistant-model.js";
2
+
3
+ /**
4
+ * Append an `ignored: true` synthetic user message to a session.
5
+ *
6
+ * Used for user-facing informational panels that must NOT trigger an agent
7
+ * turn (e.g. status output, the external-directory restriction notice). The
8
+ * message renders under the current agent (resolved from recent messages) so
9
+ * it shows in the right place in the OpenCode UI, and carries `noReply: true`
10
+ * so no LLM call is made.
11
+ *
12
+ * IMPORTANT (cache + crash safety): this path deliberately passes ONLY `agent`
13
+ * (never model/variant). OpenCode crashes if model/variant are supplied on a
14
+ * `noReply: true` prompt, and omitting them keeps the synthetic message from
15
+ * busting the provider prefix cache the previous assistant turn warmed.
16
+ *
17
+ * Lives in `shared/` (not `index.ts`) because `index.ts` must export only the
18
+ * plugin default; both the plugin entry and `tools/permissions.ts` call this.
19
+ */
20
+ export async function sendIgnoredMessage(
21
+ client: unknown,
22
+ sessionID: string,
23
+ text: string,
24
+ ): Promise<void> {
25
+ const typedClient = client as {
26
+ session?: {
27
+ prompt?: (input: unknown) => unknown;
28
+ promptAsync?: (input: unknown) => unknown;
29
+ };
30
+ };
31
+
32
+ let agent: string | undefined;
33
+ try {
34
+ const ctx = await resolvePromptContext(
35
+ client as Parameters<typeof resolvePromptContext>[0],
36
+ sessionID,
37
+ );
38
+ agent = ctx?.agent;
39
+ } catch {
40
+ agent = undefined;
41
+ }
42
+
43
+ const body: Record<string, unknown> = {
44
+ noReply: true,
45
+ parts: [{ type: "text", text, ignored: true }],
46
+ };
47
+ if (agent) body.agent = agent;
48
+ const promptInput = { path: { id: sessionID }, body };
49
+
50
+ if (typeof typedClient.session?.prompt === "function") {
51
+ await Promise.resolve(typedClient.session.prompt(promptInput));
52
+ return;
53
+ }
54
+
55
+ if (typeof typedClient.session?.promptAsync === "function") {
56
+ await typedClient.session.promptAsync(promptInput);
57
+ return;
58
+ }
59
+
60
+ throw new Error("[aft-plugin] client.session.prompt is unavailable");
61
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * In-memory notification queue for server → TUI push.
3
+ *
4
+ * Also tracks whether a TUI client is actively connected (polling).
5
+ * The server plugin cannot use `process.env.OPENCODE_CLIENT` to detect TUI
6
+ * because the server runs in a separate process from the TUI client.
7
+ */
8
+
9
+ export interface RpcNotification {
10
+ id: number;
11
+ type: string;
12
+ payload: Record<string, unknown>;
13
+ sessionId?: string;
14
+ }
15
+
16
+ let queue: RpcNotification[] = [];
17
+ let nextNotificationId = 1;
18
+ // Timestamp of last drain — used to detect if a TUI is actively polling.
19
+ // The TUI polls every 500ms; we consider it connected if it polled within
20
+ // the last 3 seconds (6× the poll interval, tolerates transient delays).
21
+ //
22
+ // PER-SESSION: a single server process can serve MANY sessions (e.g. a TUI on
23
+ // session A plus an OpenCode Desktop opened on session B for the same project,
24
+ // whose newer RPC server this TUI's port discovery then selects). The TUI
25
+ // poller drains with ITS active session id, so a session is "TUI-connected"
26
+ // only if a TUI recently drained FOR THAT session. A process-global timestamp
27
+ // would make session B's producers (`/aft-status`, configure warnings, etc.)
28
+ // take the TUI-dialog path because session A's TUI is polling — queuing a
29
+ // B-scoped dialog action that A's poller correctly refuses to show, so B's
30
+ // notice is lost (it also suppressed B's non-TUI fallback). Tracking drains per
31
+ // session routes each producer to the right delivery path.
32
+ const lastDrainAtBySession = new Map<string, number>();
33
+ let lastDrainAtAny = 0;
34
+ const TUI_CONNECTED_WINDOW_MS = 3_000;
35
+
36
+ /** Push a notification for the TUI to pick up via polling. */
37
+ export function pushNotification(
38
+ type: string,
39
+ payload: Record<string, unknown>,
40
+ sessionId?: string,
41
+ ): void {
42
+ queue.push({ id: nextNotificationId++, type, payload, sessionId });
43
+ // Cap queue size to prevent unbounded growth if a TUI is not draining.
44
+ // Session-fair eviction: a naive `slice(-50)` drops the globally-oldest
45
+ // items, so a noisy session could evict ANOTHER session's single unseen
46
+ // notification. Instead, always retain each session's newest item, then
47
+ // fill the rest of the budget with the newest overall — no session can
48
+ // starve another's pending dialog out of the window.
49
+ if (queue.length > 100) {
50
+ const newestPerSession = new Map<string | undefined, number>();
51
+ for (const notification of queue) {
52
+ const previous = newestPerSession.get(notification.sessionId);
53
+ if (previous === undefined || notification.id > previous) {
54
+ newestPerSession.set(notification.sessionId, notification.id);
55
+ }
56
+ }
57
+ const mustKeep = new Set(newestPerSession.values());
58
+ const byNewest = [...queue].sort((a, b) => b.id - a.id);
59
+ const kept: RpcNotification[] = [];
60
+ for (const notification of byNewest) {
61
+ if (kept.length < 50 || mustKeep.has(notification.id)) kept.push(notification);
62
+ }
63
+ queue = kept.sort((a, b) => a.id - b.id);
64
+ }
65
+ }
66
+
67
+ /** Return pending notifications after acking the client's last received id.
68
+ * Updates lastDrainAt so isTuiConnected() reflects recent activity.
69
+ *
70
+ * Session scoping: when `sessionId` is provided, only notifications tagged for
71
+ * that session (or session-less/global ones) are returned and pruned — a
72
+ * notification tagged for a DIFFERENT session is never handed to this client
73
+ * and is never pruned by this client's ack. This matters because the in-memory
74
+ * queue is per-process but a TUI can end up draining a process that also serves
75
+ * OTHER sessions: e.g. opening OpenCode Desktop on the same project starts a
76
+ * newer RPC server that the TUI's port discovery (newest-pid-wins) then selects,
77
+ * so a Desktop-session dialog action would otherwise surface in an unrelated
78
+ * TUI session. Each client also tracks its own `lastReceivedId`, so a global
79
+ * watermark prune would let session A's ack drop session B's still-unseen
80
+ * notification — scoping the prune to the acking session prevents that too.
81
+ *
82
+ * Delivery is at-least-once (non-destructive return + prune-on-ack): a returned
83
+ * notification stays queued until a later call acks it via a higher
84
+ * `lastReceivedId`, so a lost poll response re-delivers on the next poll. */
85
+ export function drainNotifications(lastReceivedId = 0, sessionId?: string): RpcNotification[] {
86
+ const now = Date.now();
87
+ lastDrainAtAny = now;
88
+ if (sessionId !== undefined) lastDrainAtBySession.set(sessionId, now);
89
+ const matchesClient = (notification: RpcNotification): boolean =>
90
+ sessionId === undefined ||
91
+ notification.sessionId === undefined ||
92
+ notification.sessionId === sessionId;
93
+ if (lastReceivedId > 0) {
94
+ // Prune only notifications THIS client both owns (session-matched) and has
95
+ // acked (id <= lastReceivedId). Other sessions' notifications survive.
96
+ queue = queue.filter(
97
+ (notification) => !(notification.id <= lastReceivedId && matchesClient(notification)),
98
+ );
99
+ }
100
+ return queue.filter(
101
+ (notification) => notification.id > lastReceivedId && matchesClient(notification),
102
+ );
103
+ }
104
+
105
+ /** Whether a TUI client is actively polling for notifications.
106
+ * Returns true only if a TUI has drained within the last 3 seconds.
107
+ *
108
+ * Pass `sessionId` (preferred) to ask whether a TUI is polling FOR THAT
109
+ * SESSION — this is what producers (`/aft-status`, configure warnings, etc.)
110
+ * must use to decide dialog-vs-message, so a TUI on a different session in the
111
+ * same process does not misroute their delivery. Omit it only for legacy/global
112
+ * callers that genuinely have no session context; they fall back to "any
113
+ * session recently drained" (the pre-per-session behavior). */
114
+ export function isTuiConnected(sessionId?: string): boolean {
115
+ const now = Date.now();
116
+ if (sessionId !== undefined) {
117
+ const at = lastDrainAtBySession.get(sessionId) ?? 0;
118
+ return at > 0 && now - at < TUI_CONNECTED_WINDOW_MS;
119
+ }
120
+ return lastDrainAtAny > 0 && now - lastDrainAtAny < TUI_CONNECTED_WINDOW_MS;
121
+ }
@@ -1,15 +1,20 @@
1
- import { createHash } from "node:crypto";
2
1
  import { join } from "node:path";
2
+ import { projectRootKeyHash } from "@cortexkit/aft-bridge";
3
3
 
4
4
  /**
5
- * Compute a stable hash for a project directory.
6
- * Used to scope RPC port files per-project so multiple
7
- * OpenCode Desktop instances don't overwrite each other.
5
+ * Compute a stable hash for a project directory, used to scope RPC port files
6
+ * per-project so multiple OpenCode Desktop instances don't overwrite each
7
+ * other.
8
+ *
9
+ * Delegates to the shared `projectRootKeyHash`, which CANONICALIZES (realpath +
10
+ * Windows normalization) before hashing — the same identity the bridge pool
11
+ * routes by. Previously this hashed the RAW directory string, so a symlinked or
12
+ * raw-spelled launch dir scoped its port file to a different directory than the
13
+ * bridge routed to, leaving the sidebar unable to discover the live server
14
+ * (stale-port / wrong-project class).
8
15
  */
9
16
  export function projectHash(directory: string): string {
10
- // Normalize: strip trailing slashes
11
- const normalized = directory.replace(/\/+$/, "");
12
- return createHash("sha256").update(normalized).digest("hex").slice(0, 16);
17
+ return projectRootKeyHash(directory);
13
18
  }
14
19
 
15
20
  /**
package/src/tui/index.tsx CHANGED
@@ -26,8 +26,6 @@ import {
26
26
  // per-server token on every RPC request; legacy integer port files are still
27
27
  // tolerated for already-running older server plugins.
28
28
 
29
- const STATUS_COMMAND = "aft-status";
30
-
31
29
  // RPC clients keyed by directory — one per project
32
30
  const rpcClients = new Map<string, AftRpcClient>();
33
31
 
@@ -52,6 +50,39 @@ function getSessionId(api: TuiPluginApi): string | null {
52
50
  return null;
53
51
  }
54
52
 
53
+ interface TuiMessage {
54
+ id: number;
55
+ type: string;
56
+ payload: Record<string, unknown>;
57
+ sessionId?: string;
58
+ }
59
+
60
+ const lastReceivedNotificationIdBySession = new Map<string, number>();
61
+
62
+ /** Poll for pending server → TUI notifications via RPC. */
63
+ async function consumeTuiMessages(client: AftRpcClient, sessionId: string): Promise<TuiMessage[]> {
64
+ try {
65
+ const result = await client.call<{ messages?: TuiMessage[] }>("consume-notifications", {
66
+ lastReceivedId: lastReceivedNotificationIdBySession.get(sessionId) ?? 0,
67
+ sessionId,
68
+ });
69
+ return (result.messages ?? []).map((message) => ({
70
+ id: message.id,
71
+ type: message.type,
72
+ payload: message.payload,
73
+ sessionId: message.sessionId,
74
+ }));
75
+ } catch {
76
+ return [];
77
+ }
78
+ }
79
+
80
+ function markTuiMessagesReceived(sessionId: string, messages: TuiMessage[]): void {
81
+ const previous = lastReceivedNotificationIdBySession.get(sessionId) ?? 0;
82
+ const next = messages.reduce((max, message) => Math.max(max, message.id), previous);
83
+ if (next > previous) lastReceivedNotificationIdBySession.set(sessionId, next);
84
+ }
85
+
55
86
  // ---------------------------------------------------------------------------
56
87
  // StatusDialog — themed, two-column JSX dialog. Modeled on the magic-context
57
88
  // /ctx-status pattern (packages/plugin/src/tui/index.tsx in that repo):
@@ -133,7 +164,6 @@ interface StatusDialogProps {
133
164
  sessionID: string;
134
165
  initial: AftStatusSnapshot | null;
135
166
  initialError: string | null;
136
- onClose: () => void;
137
167
  }
138
168
 
139
169
  /**
@@ -576,39 +606,40 @@ async function showStatusDialog(api: TuiPluginApi): Promise<void> {
576
606
  initialError = "AFT is starting up. Status will refresh automatically...";
577
607
  }
578
608
 
579
- const dismissStatusDialog = () => {
580
- api.ui.dialog.setSize("medium");
581
- api.ui.dialog.clear();
582
- };
583
-
609
+ // The host's DialogProvider already wraps every replace()'d element in its own
610
+ // <Dialog> frame (absolute, centered, zIndex 3000) and binds Esc/Ctrl-C to pop
611
+ // the stack. Rendering our own <api.ui.Dialog> inside it was a frame-inside-a-
612
+ // frame: the inner absolute box anchored within the already-narrowed parent and
613
+ // rendered shifted down-right off the screen edge, unthemed, with key focus on
614
+ // the wrong layer. Pass the bare component — the host frames it — exactly like
615
+ // the DialogAlert/DialogConfirm paths and magic-context's status dialog do.
616
+ //
617
+ // Also: do NOT pass an onClose that calls dialog.clear(). The host invokes the
618
+ // entry's onClose on Esc AND clear() re-invokes every entry's onClose, so a
619
+ // clear()-based onClose recursed infinitely on close. The host pops the stack
620
+ // itself, and StatusDialog stops its own polling via onCleanup when unmounted,
621
+ // so no custom onClose is needed. `replace` resets size to "medium", so request
622
+ // "large" AFTER it.
623
+ api.ui.dialog.replace(() => (
624
+ <StatusDialog
625
+ api={api}
626
+ client={client}
627
+ directory={directory}
628
+ sessionID={sessionID}
629
+ initial={initial}
630
+ initialError={initialError}
631
+ />
632
+ ));
584
633
  api.ui.dialog.setSize("large");
585
- api.ui.dialog.replace(
586
- () => (
587
- <api.ui.Dialog size="large" onClose={dismissStatusDialog}>
588
- <StatusDialog
589
- api={api}
590
- client={client}
591
- directory={directory}
592
- sessionID={sessionID}
593
- initial={initial}
594
- initialError={initialError}
595
- onClose={dismissStatusDialog}
596
- />
597
- </api.ui.Dialog>
598
- ),
599
- dismissStatusDialog,
600
- );
601
634
  }
602
635
 
603
636
  /**
604
- * Register the `/aft-status` slash command, preferring the v1.14.42+ keymap
605
- * API and falling back to the legacy `api.command.register` for older hosts.
637
+ * Register the AFT status palette command, preferring the v1.14.42+ keymap API
638
+ * and falling back to the legacy `api.command.register` for older hosts.
606
639
  *
607
- * The `keymap.registerLayer` shape uses `name`/`title`/`run`/`namespace`/
608
- * `slashName` (see `@opencode-ai/plugin/tui` types) and is what the host's
609
- * own legacy command-shim translates into. Calling it directly skips the
610
- * deprecation warning and works without depending on the (now-deprecated)
611
- * `api.command` namespace existing at all.
640
+ * No slash entry is registered here. The server config hook owns the single
641
+ * `/aft-status` slash registration, while this palette command keeps Ctrl+P
642
+ * discovery in the TUI.
612
643
  */
613
644
  function registerStatusCommand(api: TuiPluginApi): void {
614
645
  type ApiAny = {
@@ -632,7 +663,6 @@ function registerStatusCommand(api: TuiPluginApi): void {
632
663
  name: "aft.status",
633
664
  title: "AFT: Status",
634
665
  category: "AFT",
635
- slashName: STATUS_COMMAND,
636
666
  run() {
637
667
  void showStatusDialog(api);
638
668
  },
@@ -649,7 +679,6 @@ function registerStatusCommand(api: TuiPluginApi): void {
649
679
  title: "AFT: Status",
650
680
  value: "aft.status",
651
681
  category: "AFT",
652
- slash: { name: STATUS_COMMAND },
653
682
  onSelect() {
654
683
  void showStatusDialog(api);
655
684
  },
@@ -658,9 +687,8 @@ function registerStatusCommand(api: TuiPluginApi): void {
658
687
  return;
659
688
  }
660
689
 
661
- // Neither API surface is present. The TUI host can still load we only
662
- // lose the slash-command entry point. The sidebar (registered above)
663
- // remains available so users can still see AFT status visually.
690
+ // Neither API surface is present. The TUI host can still load; users can
691
+ // still see AFT status through the sidebar and server-owned slash command.
664
692
  }
665
693
 
666
694
  async function showStartupNotifications(api: TuiPluginApi): Promise<void> {
@@ -732,8 +760,8 @@ const tui: TuiPlugin = async (api) => {
732
760
  try {
733
761
  api.slots.register(await createAftSidebarSlot(api, packageVersion));
734
762
  } catch {
735
- // Older OpenCode TUI hosts may not implement api.slots; fall through
736
- // and keep the slash command working.
763
+ // Older OpenCode TUI hosts may not implement api.slots; fall through and
764
+ // keep the palette command / server-owned slash command working.
737
765
  }
738
766
 
739
767
  // OpenCode 1.14.42 removed `api.command.register` entirely
@@ -746,6 +774,58 @@ const tui: TuiPlugin = async (api) => {
746
774
  // See https://github.com/cortexkit/aft/issues/33.
747
775
  registerStatusCommand(api);
748
776
 
777
+ // Poll for server → TUI dialog requests. The server owns the slash command,
778
+ // and this poller is how it asks the TUI process to show native UI.
779
+ let pollInFlight = false;
780
+ const messagePoller = setInterval(() => {
781
+ // Scope the drain to the TUI's active session so notifications tagged for a
782
+ // different session (served by the same RPC process) are not consumed here.
783
+ // Do not poll on non-session routes: a session-scoped action fetched while
784
+ // sessionless could otherwise be acked without being shown.
785
+ if (pollInFlight) return;
786
+
787
+ const requestedSessionId = getSessionId(api);
788
+ if (!requestedSessionId) return;
789
+
790
+ const directory = api.state.path.directory ?? "";
791
+ if (!directory) return;
792
+
793
+ pollInFlight = true;
794
+ const client = getRpcClient(directory);
795
+ void consumeTuiMessages(client, requestedSessionId)
796
+ .then(async (messages) => {
797
+ // If the user switched routes while the RPC was in flight, drop this
798
+ // batch without advancing the cursor; the next poll for the active
799
+ // session will fetch the right notifications.
800
+ if (getSessionId(api) !== requestedSessionId) return;
801
+
802
+ const orderedMessages = [...messages].sort((a, b) => a.id - b.id);
803
+ for (const message of orderedMessages) {
804
+ if (getSessionId(api) !== requestedSessionId) return;
805
+ if (message.sessionId && message.sessionId !== requestedSessionId) continue;
806
+ if (message.type !== "action") continue;
807
+ if (message.payload?.action !== "show-status-dialog") continue;
808
+ await showStatusDialog(api);
809
+ }
810
+
811
+ if (getSessionId(api) !== requestedSessionId) return;
812
+ markTuiMessagesReceived(requestedSessionId, orderedMessages);
813
+ })
814
+ .catch(() => {
815
+ // Intentional: message polling should never crash the TUI.
816
+ })
817
+ .finally(() => {
818
+ pollInFlight = false;
819
+ });
820
+ }, 500);
821
+
822
+ api.lifecycle?.onDispose?.(() => {
823
+ clearInterval(messagePoller);
824
+ lastReceivedNotificationIdBySession.clear();
825
+ for (const client of rpcClients.values()) client.reset();
826
+ rpcClients.clear();
827
+ });
828
+
749
829
  // Show startup notifications — RPC server is already running by the time TUI loads
750
830
  void showStartupNotifications(api);
751
831
  };
@@ -7,9 +7,9 @@
7
7
  // session.updated/message.updated events with a small debounce, same as
8
8
  // magic-context, so the panel stays current without polling.
9
9
 
10
+ import { canonicalizeProjectRoot } from "@cortexkit/aft-bridge";
10
11
  import type { TuiPluginApi, TuiSlotPlugin, TuiThemeCurrent } from "@opencode-ai/plugin/tui";
11
12
  import { createEffect, createMemo, createSignal, on, onCleanup } from "solid-js";
12
-
13
13
  import { AftRpcClient } from "../shared/rpc-client";
14
14
  import {
15
15
  type AftStatusSnapshot,
@@ -317,16 +317,22 @@ export function isSnapshotForContext(
317
317
  directory: string,
318
318
  servedDirectory: string | undefined,
319
319
  ): boolean {
320
- const stripSlash = (p: string) => p.replace(/\/+$/, "");
320
+ // Canonicalize both sides through the SAME canonicalizer the bridge routes
321
+ // by, so a symlinked / `/var`-vs-`/private/var` / trailing-slash spelling of
322
+ // the sidebar directory still matches Rust's canonical_root. A raw stripSlash
323
+ // compare (the old behavior) rejected legitimate snapshots whenever the TUI
324
+ // directory and Rust's root were different spellings of the same location,
325
+ // leaving the sidebar blank on aliased roots.
326
+ const canon = (p: string) => canonicalizeProjectRoot(p);
321
327
  const roots = [snapshot.project_root, snapshot.canonical_root].filter(
322
328
  (r): r is string => typeof r === "string" && r.length > 0,
323
329
  );
324
330
  if (roots.length === 0) return true; // placeholder / synthetic
325
- const dir = stripSlash(directory);
326
- if (roots.some((r) => stripSlash(r) === dir)) return true;
331
+ const dir = canon(directory);
332
+ if (roots.some((r) => canon(r) === dir)) return true;
327
333
  if (typeof servedDirectory === "string" && servedDirectory.length > 0) {
328
- const served = stripSlash(servedDirectory);
329
- return roots.some((r) => stripSlash(r) === served);
334
+ const served = canon(servedDirectory);
335
+ return roots.some((r) => canon(r) === served);
330
336
  }
331
337
  return false;
332
338
  }