@cortexkit/aft-opencode 0.30.1 → 0.30.2

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.
package/dist/tui.js CHANGED
@@ -1,78 +1,10 @@
1
1
  // src/tui/index.tsx
2
2
  import { createMemo as createMemo2, createSignal as createSignal2, onCleanup as onCleanup2 } from "solid-js";
3
3
  // package.json
4
- var package_default = {
5
- name: "@cortexkit/aft-opencode",
6
- version: "0.30.1",
7
- type: "module",
8
- description: "OpenCode plugin for Agent File Tools (AFT) — tree-sitter and lsp powered code analysis",
9
- main: "dist/index.js",
10
- types: "dist/index.d.ts",
11
- license: "MIT",
12
- repository: {
13
- type: "git",
14
- url: "https://github.com/cortexkit/aft"
15
- },
16
- files: [
17
- "dist",
18
- "src/tui",
19
- "src/shared",
20
- "src/logger.ts",
21
- "README.md"
22
- ],
23
- scripts: {
24
- build: "bun build src/index.ts --outdir dist --target node --format esm --external @opencode-ai/plugin --external @opencode-ai/sdk && bun build src/tui/index.tsx --outfile dist/tui.js --target node --format esm --external @opencode-ai/plugin --external @opencode-ai/plugin/tui --external @opencode-ai/sdk --external @opentui/solid --external solid-js && tsc --emitDeclarationOnly",
25
- typecheck: "tsc --noEmit",
26
- pretest: "cd ../.. && cargo build",
27
- test: "bun test",
28
- "test:e2e": "bun test src/__tests__/e2e/",
29
- lint: "biome check src",
30
- schema: "bun scripts/build-schema.ts",
31
- prepublishOnly: "bun run build"
32
- },
33
- dependencies: {
34
- "@clack/prompts": "^1.2.0",
35
- "@cortexkit/aft-bridge": "0.30.1",
36
- "@opencode-ai/plugin": "^1.15.5",
37
- "@opencode-ai/sdk": "^1.15.5",
38
- "@xterm/headless": "^5.5.0",
39
- "comment-json": "^4.6.2",
40
- undici: "^7.25.0",
41
- zod: "^4.1.8"
42
- },
43
- optionalDependencies: {
44
- "@cortexkit/aft-darwin-arm64": "0.30.1",
45
- "@cortexkit/aft-darwin-x64": "0.30.1",
46
- "@cortexkit/aft-linux-arm64": "0.30.1",
47
- "@cortexkit/aft-linux-x64": "0.30.1",
48
- "@cortexkit/aft-win32-arm64": "0.30.1",
49
- "@cortexkit/aft-win32-x64": "0.30.1"
50
- },
51
- devDependencies: {
52
- "@types/node": "^22.0.0",
53
- typescript: "^5.8.0"
54
- },
55
- exports: {
56
- ".": {
57
- types: "./dist/index.d.ts",
58
- import: "./dist/index.js"
59
- },
60
- "./tui": {
61
- types: "./src/tui/index.tsx",
62
- import: "./src/tui/index.tsx"
63
- }
64
- },
65
- "oc-plugin": [
66
- "server",
67
- "tui"
68
- ],
69
- peerDependencies: {
70
- "@opencode-ai/plugin": ">=1.15.5"
71
- }
72
- };
4
+ var version = "0.30.2";
73
5
 
74
6
  // src/shared/rpc-client.ts
75
- import { existsSync, readdirSync, readFileSync } from "node:fs";
7
+ import { existsSync, readdirSync, readFileSync, unlinkSync } from "node:fs";
76
8
  import { join as join3 } from "node:path";
77
9
 
78
10
  // src/logger.ts
@@ -154,26 +86,55 @@ function rpcPortFileDir(storageDir, directory) {
154
86
  var MAX_RETRIES = 10;
155
87
  var RETRY_DELAY_MS = 500;
156
88
  var REQUEST_TIMEOUT_MS = 5000;
89
+ function abortError(signal) {
90
+ const reason = signal.reason;
91
+ if (reason instanceof Error)
92
+ return reason;
93
+ return new Error("AFT RPC request aborted");
94
+ }
95
+ function throwIfAborted(signal) {
96
+ if (signal?.aborted)
97
+ throw abortError(signal);
98
+ }
99
+ function delay(ms, signal) {
100
+ throwIfAborted(signal);
101
+ return new Promise((resolve, reject) => {
102
+ let timer;
103
+ const onAbort = () => {
104
+ clearTimeout(timer);
105
+ reject(signal ? abortError(signal) : new Error("AFT RPC request aborted"));
106
+ };
107
+ timer = setTimeout(() => {
108
+ signal?.removeEventListener("abort", onAbort);
109
+ resolve();
110
+ }, ms);
111
+ signal?.addEventListener("abort", onAbort, { once: true });
112
+ });
113
+ }
157
114
 
158
115
  class AftRpcClient {
159
116
  port = null;
160
117
  token = null;
161
118
  portsDir;
162
119
  legacyPortFile;
120
+ stalePortFailures = new Map;
163
121
  constructor(storageDir, directory) {
164
122
  this.portsDir = rpcPortFileDir(storageDir, directory);
165
123
  this.legacyPortFile = rpcPortFilePath(storageDir, directory);
166
124
  }
167
- async call(method, params = {}) {
168
- const infos = await this.resolvePortInfos();
125
+ async call(method, params = {}, options = {}) {
126
+ const { signal } = options;
127
+ throwIfAborted(signal);
128
+ const infos = await this.resolvePortInfos(signal);
169
129
  if (infos.length === 0) {
170
130
  throw new Error("AFT RPC server not available");
171
131
  }
172
132
  let placeholder = null;
173
133
  let lastError = null;
174
134
  for (const info of infos) {
135
+ throwIfAborted(signal);
175
136
  try {
176
- const result = await this.callOne(method, params, info);
137
+ const result = await this.callOne(method, params, info, signal);
177
138
  if (this.looksLikePlaceholder(result)) {
178
139
  placeholder = result;
179
140
  continue;
@@ -182,6 +143,7 @@ class AftRpcClient {
182
143
  this.token = info.token;
183
144
  return result;
184
145
  } catch (err) {
146
+ throwIfAborted(signal);
185
147
  lastError = err;
186
148
  }
187
149
  }
@@ -189,12 +151,12 @@ class AftRpcClient {
189
151
  return placeholder;
190
152
  throw lastError instanceof Error ? lastError : new Error(String(lastError));
191
153
  }
192
- async callOne(method, params, info) {
154
+ async callOne(method, params, info, signal) {
193
155
  const response = await this.fetchWithTimeout(`http://127.0.0.1:${info.port}/rpc/${method}`, {
194
156
  method: "POST",
195
157
  headers: { "Content-Type": "application/json" },
196
158
  body: JSON.stringify({ ...params, token: info.token })
197
- });
159
+ }, signal);
198
160
  if (!response.ok) {
199
161
  const text = await response.text();
200
162
  throw new Error(`RPC ${method} failed (${response.status}): ${text}`);
@@ -215,49 +177,60 @@ class AftRpcClient {
215
177
  return false;
216
178
  }
217
179
  }
218
- async resolvePortInfos() {
180
+ async resolvePortInfos(signal) {
219
181
  for (let attempt = 0;attempt < MAX_RETRIES; attempt++) {
182
+ throwIfAborted(signal);
220
183
  const infos = this.readAllPortFiles();
221
184
  if (infos.length > 0) {
222
185
  const alive = [];
223
186
  for (const info of infos) {
224
- if (await this.healthCheck(info.port)) {
187
+ throwIfAborted(signal);
188
+ if (await this.healthCheck(info.port, signal)) {
189
+ this.clearPortFailure(info);
225
190
  alive.push(info);
191
+ } else {
192
+ this.recordPortFailure(info);
226
193
  }
227
194
  }
228
195
  if (alive.length > 0)
229
196
  return alive;
230
197
  }
231
198
  if (attempt < MAX_RETRIES - 1) {
232
- await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
199
+ await delay(RETRY_DELAY_MS, signal);
233
200
  }
234
201
  }
235
202
  return [];
236
203
  }
237
204
  readAllPortFiles() {
238
205
  const collected = [];
206
+ const seenPorts = new Set;
207
+ const add = (info) => {
208
+ if (seenPorts.has(info.port))
209
+ return;
210
+ seenPorts.add(info.port);
211
+ collected.push(info);
212
+ };
239
213
  if (existsSync(this.portsDir)) {
240
214
  try {
241
- const entries = readdirSync(this.portsDir);
215
+ const entries = readdirSync(this.portsDir).sort();
242
216
  for (const entry of entries) {
243
217
  if (!entry.endsWith(".json"))
244
218
  continue;
245
- const info = this.parsePortFile(join3(this.portsDir, entry));
219
+ const filePath = join3(this.portsDir, entry);
220
+ const info = this.parsePortFile(filePath);
246
221
  if (info)
247
- collected.push(info);
222
+ add({ ...info, source: "instance", path: filePath });
248
223
  }
249
224
  } catch {}
250
225
  }
251
- if (collected.length === 0) {
252
- const info = this.parsePortFile(this.legacyPortFile);
253
- if (info)
254
- collected.push(info);
255
- }
226
+ const legacyInfo = this.parsePortFile(this.legacyPortFile);
227
+ if (legacyInfo)
228
+ add({ ...legacyInfo, source: "legacy", path: this.legacyPortFile });
256
229
  return collected;
257
230
  }
258
- parsePortFile(path2) {
231
+ parsePortFile(filePath) {
259
232
  try {
260
- const content = readFileSync(path2, "utf-8").trim();
233
+ const content = readFileSync(filePath, "utf-8").trim();
261
234
  let port;
262
235
  let token;
263
236
  if (content.startsWith("{")) {
@@ -277,28 +250,59 @@ class AftRpcClient {
277
250
  return null;
278
251
  }
279
252
  }
280
- async healthCheck(port) {
253
+ portFailureKey(info) {
254
+ if (info.source !== "instance" || !info.path)
255
+ return null;
256
+ return `${info.path}\x00${info.port}\x00${info.token ?? ""}`;
257
+ }
258
+ clearPortFailure(info) {
259
+ const key = this.portFailureKey(info);
260
+ if (key)
261
+ this.stalePortFailures.delete(key);
262
+ }
263
+ recordPortFailure(info) {
264
+ const key = this.portFailureKey(info);
265
+ if (!key || !info.path)
266
+ return;
267
+ const failures = (this.stalePortFailures.get(key) ?? 0) + 1;
268
+ if (failures < 2) {
269
+ this.stalePortFailures.set(key, failures);
270
+ return;
271
+ }
272
+ this.stalePortFailures.delete(key);
281
273
  try {
282
- const response = await this.fetchWithTimeout(`http://127.0.0.1:${port}/health`, {
283
- method: "GET"
284
- });
274
+ const current = this.parsePortFile(info.path);
275
+ if (current?.port === info.port && current.token === info.token) {
276
+ unlinkSync(info.path);
277
+ }
278
+ } catch {}
279
+ }
280
+ async healthCheck(port, signal) {
281
+ try {
282
+ const response = await this.fetchWithTimeout(`http://127.0.0.1:${port}/health`, { method: "GET" }, signal);
285
283
  return response.ok;
286
284
  } catch {
285
+ throwIfAborted(signal);
287
286
  return false;
288
287
  }
289
288
  }
290
- async fetchWithTimeout(url, options) {
289
+ async fetchWithTimeout(url, options, signal) {
290
+ throwIfAborted(signal);
291
291
  const controller = new AbortController;
292
292
  const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
293
+ const onAbort = () => controller.abort();
294
+ signal?.addEventListener("abort", onAbort, { once: true });
293
295
  try {
294
296
  return await fetch(url, { ...options, signal: controller.signal });
295
297
  } finally {
296
298
  clearTimeout(timeout);
299
+ signal?.removeEventListener("abort", onAbort);
297
300
  }
298
301
  }
299
302
  reset() {
300
303
  this.port = null;
301
304
  this.token = null;
305
+ this.stalePortFailures.clear();
302
306
  }
303
307
  }
304
308
 
@@ -418,6 +422,28 @@ function coerceAftStatus(response) {
418
422
 
419
423
  // src/tui/sidebar.tsx
420
424
  import { createEffect, createMemo, createSignal, on, onCleanup } from "solid-js";
425
+
426
+ // src/shared/storage-paths.ts
427
+ import { homedir } from "node:os";
428
+ import { join as join4 } from "node:path";
429
+ function homeDir() {
430
+ if (process.platform === "win32")
431
+ return process.env.USERPROFILE || process.env.HOME || homedir();
432
+ return process.env.HOME || homedir();
433
+ }
434
+ function dataHome() {
435
+ if (process.env.XDG_DATA_HOME)
436
+ return process.env.XDG_DATA_HOME;
437
+ if (process.platform === "win32") {
438
+ return process.env.LOCALAPPDATA || process.env.APPDATA || join4(homeDir(), "AppData", "Local");
439
+ }
440
+ return join4(homeDir(), ".local", "share");
441
+ }
442
+ function resolveCortexKitStorageRoot() {
443
+ return join4(dataHome(), "cortexkit", "aft");
444
+ }
445
+
446
+ // src/tui/sidebar.tsx
421
447
  import { jsxDEV, Fragment } from "@opentui/solid/jsx-dev-runtime";
422
448
  var SINGLE_BORDER = { type: "single" };
423
449
  var REFRESH_DEBOUNCE_MS = 200;
@@ -520,45 +546,92 @@ var SectionHeader = (props) => /* @__PURE__ */ jsxDEV("box", {
520
546
  }, undefined, false, undefined, this)
521
547
  }, undefined, false, undefined, this)
522
548
  }, undefined, false, undefined, this);
549
+ function resolveTuiStorageDir() {
550
+ return resolveCortexKitStorageRoot();
551
+ }
523
552
  var sidebarClients = new Map;
524
553
  function getClient(directory) {
525
554
  let client = sidebarClients.get(directory);
526
555
  if (client)
527
556
  return client;
528
- const home = process.env.HOME || process.env.USERPROFILE || "";
529
- const dataHome = process.env.XDG_DATA_HOME || `${home}/.local/share`;
530
- const storageDir = `${dataHome}/cortexkit/aft`;
531
- client = new AftRpcClient(storageDir, directory);
557
+ client = new AftRpcClient(resolveTuiStorageDir(), directory);
532
558
  sidebarClients.set(directory, client);
533
559
  return client;
534
560
  }
561
+ function scopedSidebarSnapshot(scoped, directory, sessionID) {
562
+ if (!scoped)
563
+ return null;
564
+ if (scoped.directory !== directory || scoped.sessionID !== sessionID)
565
+ return null;
566
+ return scoped.snapshot;
567
+ }
535
568
  var SidebarContent = (props) => {
536
569
  const [status, setStatus] = createSignal(null);
537
- let inflight = false;
570
+ let inflight = null;
571
+ let generation = 0;
538
572
  let debounceTimer;
539
573
  let pollTimer;
540
- const refresh = async () => {
541
- const sid = props.sessionID();
542
- if (!sid)
574
+ const currentDirectory = () => props.api.state.path.directory ?? "";
575
+ const requestRender = () => {
576
+ try {
577
+ props.api.renderer.requestRender();
578
+ } catch {}
579
+ };
580
+ const abortInflight = () => {
581
+ if (!inflight)
543
582
  return;
544
- if (inflight)
583
+ inflight.controller.abort();
584
+ inflight = null;
585
+ };
586
+ const clearStatusForContext = (directory, sessionID) => {
587
+ const current = status();
588
+ if (!current)
545
589
  return;
546
- const directory = props.api.state.path.directory ?? "";
547
- if (!directory)
590
+ if (current.directory === directory && current.sessionID === sessionID)
548
591
  return;
549
- inflight = true;
592
+ setStatus(null);
593
+ requestRender();
594
+ };
595
+ const refresh = async () => {
596
+ const sid = props.sessionID();
597
+ const directory = currentDirectory();
598
+ if (!sid || !directory) {
599
+ generation++;
600
+ abortInflight();
601
+ if (status()) {
602
+ setStatus(null);
603
+ requestRender();
604
+ }
605
+ return;
606
+ }
607
+ clearStatusForContext(directory, sid);
608
+ if (inflight) {
609
+ if (inflight.directory === directory && inflight.sessionID === sid)
610
+ return;
611
+ generation++;
612
+ abortInflight();
613
+ }
614
+ const requestGeneration = ++generation;
615
+ const controller = new AbortController;
616
+ inflight = { controller, generation: requestGeneration, directory, sessionID: sid };
550
617
  try {
551
618
  const client = getClient(directory);
552
- const response = await client.call("status", { sessionID: sid });
619
+ const response = await client.call("status", { sessionID: sid }, { signal: controller.signal });
620
+ if (controller.signal.aborted || requestGeneration !== generation)
621
+ return;
622
+ if (currentDirectory() !== directory || props.sessionID() !== sid)
623
+ return;
553
624
  if (response && response.success !== false) {
554
625
  const snapshot = coerceAftStatus(response);
555
- setStatus(snapshot);
556
- try {
557
- props.api.renderer.requestRender();
558
- } catch {}
626
+ setStatus({ directory, sessionID: sid, snapshot });
627
+ requestRender();
559
628
  }
560
- } catch {} finally {
561
- inflight = false;
629
+ } catch {
630
+ if (controller.signal.aborted || requestGeneration !== generation)
631
+ return;
632
+ } finally {
633
+ if (inflight?.generation === requestGeneration)
634
+ inflight = null;
562
635
  }
563
636
  };
564
637
  const scheduleRefresh = () => {
@@ -570,6 +643,8 @@ var SidebarContent = (props) => {
570
643
  }, REFRESH_DEBOUNCE_MS);
571
644
  };
572
645
  onCleanup(() => {
646
+ generation++;
647
+ abortInflight();
573
648
  if (debounceTimer)
574
649
  clearTimeout(debounceTimer);
575
650
  if (pollTimer)
@@ -604,13 +679,15 @@ var SidebarContent = (props) => {
604
679
  unsub();
605
680
  } catch {}
606
681
  }
682
+ generation++;
683
+ abortInflight();
607
684
  if (pollTimer) {
608
685
  clearInterval(pollTimer);
609
686
  pollTimer = undefined;
610
687
  }
611
688
  });
612
689
  }, { defer: false }));
613
- const s = createMemo(() => status());
690
+ const s = () => scopedSidebarSnapshot(status(), currentDirectory(), props.sessionID());
614
691
  const notInitialized = () => s()?.cache_role === "not_initialized";
615
692
  const searchStatus = () => statusDisplay(s()?.search_index?.status ?? "disabled");
616
693
  const semanticStatus = () => statusDisplay(s()?.semantic_index?.status ?? "disabled");
@@ -821,10 +898,7 @@ function getRpcClient(directory) {
821
898
  let client = rpcClients.get(directory);
822
899
  if (client)
823
900
  return client;
824
- const home = process.env.HOME || process.env.USERPROFILE || "";
825
- const dataHome = process.env.XDG_DATA_HOME || `${home}/.local/share`;
826
- const storageDir = `${dataHome}/cortexkit/aft`;
827
- client = new AftRpcClient(storageDir, directory);
901
+ client = new AftRpcClient(resolveTuiStorageDir(), directory);
828
902
  rpcClients.set(directory, client);
829
903
  return client;
830
904
  }
@@ -902,16 +976,41 @@ var StatusDialog = (props) => {
902
976
  const t = () => theme();
903
977
  const [status, setStatus] = createSignal2(props.initial);
904
978
  const [error, setError] = createSignal2(props.initialError);
905
- const timer = setInterval(async () => {
979
+ let pollGeneration = 0;
980
+ let pollController = null;
981
+ const pollStatus = async () => {
982
+ if (pollController)
983
+ return;
984
+ const controller = new AbortController;
985
+ const requestGeneration = ++pollGeneration;
986
+ pollController = controller;
906
987
  try {
907
- const response = await props.client.call("status", { sessionID: props.sessionID });
988
+ const response = await props.client.call("status", { sessionID: props.sessionID }, { signal: controller.signal });
989
+ if (controller.signal.aborted || requestGeneration !== pollGeneration)
990
+ return;
908
991
  if (response.success !== false) {
909
992
  setStatus(coerceAftStatus(response));
910
993
  setError(null);
911
994
  }
912
- } catch {}
995
+ } catch {
996
+ if (controller.signal.aborted || requestGeneration !== pollGeneration)
997
+ return;
998
+ } finally {
999
+ if (pollController === controller)
1000
+ pollController = null;
1001
+ }
1002
+ };
1003
+ const timer = setInterval(() => {
1004
+ pollStatus();
913
1005
  }, POLL_INTERVAL_MS2);
914
- onCleanup2(() => clearInterval(timer));
1006
+ onCleanup2(() => {
1007
+ clearInterval(timer);
1008
+ pollGeneration++;
1009
+ if (pollController) {
1010
+ pollController.abort();
1011
+ pollController = null;
1012
+ }
1013
+ });
915
1014
  const cacheRoleTone = (role) => role === "main" ? "accent" : role === "worktree" ? "warn" : "muted";
916
1015
  const compressionAggregateRows = () => formatCompressionSidebarRows(status()?.compression);
917
1016
  return /* @__PURE__ */ jsxDEV2("box", {
@@ -939,7 +1038,7 @@ var StatusDialog = (props) => {
939
1038
  fg: t().textMuted,
940
1039
  children: [
941
1040
  "v",
942
- status()?.version ?? package_default.version
1041
+ status()?.version ?? version
943
1042
  ]
944
1043
  }, undefined, true, undefined, this)
945
1044
  ]
@@ -1346,7 +1445,7 @@ ${featureText}`;
1346
1445
  }
1347
1446
  var tui = async (api) => {
1348
1447
  try {
1349
- api.slots.register(createAftSidebarSlot(api, package_default.version));
1448
+ api.slots.register(createAftSidebarSlot(api, version));
1350
1449
  } catch {}
1351
1450
  registerStatusCommand(api);
1352
1451
  showStartupNotifications(api);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/aft-opencode",
3
- "version": "0.30.1",
3
+ "version": "0.30.2",
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",
@@ -29,7 +29,7 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "@clack/prompts": "^1.2.0",
32
- "@cortexkit/aft-bridge": "0.30.1",
32
+ "@cortexkit/aft-bridge": "0.30.2",
33
33
  "@opencode-ai/plugin": "^1.15.5",
34
34
  "@opencode-ai/sdk": "^1.15.5",
35
35
  "@xterm/headless": "^5.5.0",
@@ -38,12 +38,12 @@
38
38
  "zod": "^4.1.8"
39
39
  },
40
40
  "optionalDependencies": {
41
- "@cortexkit/aft-darwin-arm64": "0.30.1",
42
- "@cortexkit/aft-darwin-x64": "0.30.1",
43
- "@cortexkit/aft-linux-arm64": "0.30.1",
44
- "@cortexkit/aft-linux-x64": "0.30.1",
45
- "@cortexkit/aft-win32-arm64": "0.30.1",
46
- "@cortexkit/aft-win32-x64": "0.30.1"
41
+ "@cortexkit/aft-darwin-arm64": "0.30.2",
42
+ "@cortexkit/aft-darwin-x64": "0.30.2",
43
+ "@cortexkit/aft-linux-arm64": "0.30.2",
44
+ "@cortexkit/aft-linux-x64": "0.30.2",
45
+ "@cortexkit/aft-win32-arm64": "0.30.2",
46
+ "@cortexkit/aft-win32-x64": "0.30.2"
47
47
  },
48
48
  "devDependencies": {
49
49
  "@types/node": "^22.0.0",
@@ -8,12 +8,11 @@
8
8
  * prefix cache that the previous assistant turn warmed.
9
9
  *
10
10
  * APPROACH: Mirror what `opencode-xtra` does in production. Read recent
11
- * messages from `client.session.messages()`, prefer the most recent
12
- * assistant message, fall back to any role, MERGE across messages so
13
- * partial fields (e.g. agent set on user message but not assistant) are
14
- * filled in, and read BOTH the flat shape (`info.providerID`) used by
15
- * AssistantMessage and the nested shape (`info.model.providerID`) used
16
- * by UserMessage.
11
+ * messages from `client.session.messages()`, walk newest→oldest across
12
+ * roles, and MERGE field-by-field so the newest context-bearing message
13
+ * wins while older messages only fill fields it did not provide. Read BOTH
14
+ * the flat shape (`info.providerID`) used by AssistantMessage and the
15
+ * nested shape (`info.model.providerID`) used by UserMessage.
17
16
  *
18
17
  * IMPORTANT: This context is only meaningful for callers that DO trigger
19
18
  * LLM inference (e.g. background-bash idle wakes with `noReply: false`).
@@ -49,11 +48,6 @@ function extractMessages(response: unknown): unknown[] {
49
48
  return [];
50
49
  }
51
50
 
52
- function getRole(message: unknown): string | undefined {
53
- if (!isRecord(message) || !isRecord(message.info)) return undefined;
54
- return typeof message.info.role === "string" ? message.info.role : undefined;
55
- }
56
-
57
51
  function extractFromMessage(message: unknown): ResolvedPromptContext | null {
58
52
  if (!isRecord(message) || !isRecord(message.info)) return null;
59
53
  const info = message.info as RawInfo;
@@ -103,10 +97,10 @@ function isComplete(ctx: ResolvedPromptContext): boolean {
103
97
  }
104
98
 
105
99
  /**
106
- * Read recent messages from the OpenCode session and resolve the most
107
- * recent assistant prompt context. Falls back to user messages if no
108
- * assistant has the field. Merges across messages so partial fields are
109
- * filled in. Returns null if no usable context is found.
100
+ * Read recent messages from the OpenCode session and resolve the newest
101
+ * effective prompt context across roles. Merges across messages so partial
102
+ * fields are filled in from older messages only when the newer context did
103
+ * not provide them. Returns null if no usable context is found.
110
104
  *
111
105
  * Mirrors `resolveSessionPromptParams` in `opencode-xtra` (the working
112
106
  * reference implementation).
@@ -153,19 +147,7 @@ export async function resolvePromptContext(
153
147
  }
154
148
  if (messages.length === 0) return null;
155
149
 
156
- // Pass 1: prefer the most recent assistant, merge older assistants to
157
- // fill missing fields.
158
150
  let result: ResolvedPromptContext = {};
159
- for (let i = messages.length - 1; i >= 0; i -= 1) {
160
- if (getRole(messages[i]) !== "assistant") continue;
161
- const ctx = extractFromMessage(messages[i]);
162
- if (!ctx) continue;
163
- result = mergeContexts(result, ctx);
164
- if (isComplete(result)) return result;
165
- }
166
-
167
- // Pass 2: fall back to any role (covers user messages, which carry
168
- // model nested under `info.model`).
169
151
  for (let i = messages.length - 1; i >= 0; i -= 1) {
170
152
  const ctx = extractFromMessage(messages[i]);
171
153
  if (!ctx) continue;