@cortexkit/aft-opencode 0.62.0 → 0.63.5

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 (85) hide show
  1. package/dist/bash-wait-detach.d.ts +1 -0
  2. package/dist/bash-wait-detach.d.ts.map +1 -1
  3. package/dist/bg-notifications.d.ts +30 -11
  4. package/dist/bg-notifications.d.ts.map +1 -1
  5. package/dist/bridge-bootstrap.d.ts +1 -1
  6. package/dist/bridge-bootstrap.d.ts.map +1 -1
  7. package/dist/config-live-reload.d.ts +14 -2
  8. package/dist/config-live-reload.d.ts.map +1 -1
  9. package/dist/config.d.ts +37 -8
  10. package/dist/config.d.ts.map +1 -1
  11. package/dist/entry/server.js +4015 -1382
  12. package/dist/hooks/auto-update-checker/index.d.ts +5 -1
  13. package/dist/hooks/auto-update-checker/index.d.ts.map +1 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +3294 -1243
  16. package/dist/logger.d.ts +6 -10
  17. package/dist/logger.d.ts.map +1 -1
  18. package/dist/lsp-auto-install.d.ts.map +1 -1
  19. package/dist/lsp.d.ts +1 -0
  20. package/dist/lsp.d.ts.map +1 -1
  21. package/dist/shared/rpc-client.d.ts.map +1 -1
  22. package/dist/shared/rpc-notifications.d.ts.map +1 -1
  23. package/dist/shared/rpc-server.d.ts +2 -0
  24. package/dist/shared/rpc-server.d.ts.map +1 -1
  25. package/dist/shared/session-cleanup.d.ts +6 -0
  26. package/dist/shared/session-cleanup.d.ts.map +1 -0
  27. package/dist/shared/session-directory.d.ts +24 -0
  28. package/dist/shared/session-directory.d.ts.map +1 -1
  29. package/dist/shared/status.d.ts +12 -1
  30. package/dist/shared/status.d.ts.map +1 -1
  31. package/dist/shared/subagent-detect.d.ts +24 -8
  32. package/dist/shared/subagent-detect.d.ts.map +1 -1
  33. package/dist/shared/wake-admission.d.ts +68 -0
  34. package/dist/shared/wake-admission.d.ts.map +1 -0
  35. package/dist/shutdown-hooks.d.ts +2 -0
  36. package/dist/shutdown-hooks.d.ts.map +1 -1
  37. package/dist/subc-tool-schemas.d.ts.map +1 -1
  38. package/dist/tools/_shared.d.ts +5 -1
  39. package/dist/tools/_shared.d.ts.map +1 -1
  40. package/dist/tools/bash.d.ts +1 -0
  41. package/dist/tools/bash.d.ts.map +1 -1
  42. package/dist/tools/bash_watch.d.ts +4 -0
  43. package/dist/tools/bash_watch.d.ts.map +1 -1
  44. package/dist/tools/definitions/v2.d.ts +2 -1
  45. package/dist/tools/definitions/v2.d.ts.map +1 -1
  46. package/dist/tools/hoisted.d.ts.map +1 -1
  47. package/dist/tools/inspect.d.ts.map +1 -1
  48. package/dist/tools/metadata.d.ts +7 -0
  49. package/dist/tools/metadata.d.ts.map +1 -0
  50. package/dist/tools/outside-permissions.d.ts +35 -0
  51. package/dist/tools/outside-permissions.d.ts.map +1 -0
  52. package/dist/tools/permissions.d.ts +9 -11
  53. package/dist/tools/permissions.d.ts.map +1 -1
  54. package/dist/tools/reading.d.ts.map +1 -1
  55. package/dist/tools/search.d.ts.map +1 -1
  56. package/dist/tools/semantic.d.ts.map +1 -1
  57. package/dist/tui/notification-socket.d.ts.map +1 -1
  58. package/dist/tui/preferences.d.ts +11 -0
  59. package/dist/tui/preferences.d.ts.map +1 -1
  60. package/dist/v2-session-lifecycle.d.ts +7 -0
  61. package/dist/v2-session-lifecycle.d.ts.map +1 -1
  62. package/dist/v2-tool-hooks.d.ts.map +1 -1
  63. package/dist/wakes/runtime-consumer.d.ts +9 -3
  64. package/dist/wakes/runtime-consumer.d.ts.map +1 -1
  65. package/dist/wakes/session-delivery.d.ts +22 -4
  66. package/dist/wakes/session-delivery.d.ts.map +1 -1
  67. package/package.json +8 -8
  68. package/src/entry/server-runtime.mjs +53 -18
  69. package/src/logger.ts +12 -5
  70. package/src/shared/rpc-client.ts +35 -28
  71. package/src/shared/rpc-notifications.ts +5 -3
  72. package/src/shared/rpc-server.ts +65 -7
  73. package/src/shared/session-cleanup.ts +13 -0
  74. package/src/shared/session-directory.ts +59 -3
  75. package/src/shared/status.ts +32 -2
  76. package/src/shared/subagent-detect.ts +53 -25
  77. package/src/shared/wake-admission.ts +340 -0
  78. package/src/tui/notification-socket.ts +30 -4
  79. package/src/tui/preferences.ts +66 -29
  80. package/src/tui/sidebar-view.tsx +1 -0
  81. package/src/tui/v2.tsx +49 -1
  82. package/src/tui-compiled/notification-socket.ts +30 -4
  83. package/src/tui-compiled/preferences.ts +66 -29
  84. package/src/tui-compiled/sidebar-view.tsx +2 -1
  85. package/src/tui-compiled/v2.tsx +54 -1
@@ -0,0 +1,340 @@
1
+ export interface WakeSessionApi {
2
+ promptAsync(input: unknown): Promise<unknown> | unknown;
3
+ message?: (input: {
4
+ path: { id: string; messageID: string };
5
+ signal?: AbortSignal;
6
+ throwOnError?: false;
7
+ responseStyle?: "fields";
8
+ }) => Promise<unknown> | unknown;
9
+ messages?: (input: {
10
+ path: { id: string };
11
+ query?: { limit?: number };
12
+ signal?: AbortSignal;
13
+ throwOnError?: false;
14
+ responseStyle?: "fields";
15
+ }) => Promise<unknown> | unknown;
16
+ }
17
+
18
+ export interface PendingWakeAdmission {
19
+ messageID: string;
20
+ taskIDs: readonly string[];
21
+ status: "submitting" | "unknown" | "pending";
22
+ observe(info: Record<string, unknown>): void;
23
+ reconcile(): Promise<void>;
24
+ cancel(): void;
25
+ }
26
+
27
+ export class WakeAdmissionRejected extends Error {
28
+ constructor(
29
+ message: string,
30
+ readonly status: number,
31
+ ) {
32
+ super(message);
33
+ }
34
+ }
35
+
36
+ interface AdmissionOptions {
37
+ deliveryID: string;
38
+ sessionID: string;
39
+ requestBody: Record<string, unknown> & { messageID: string };
40
+ sessionApi: WakeSessionApi;
41
+ pending: Map<string, PendingWakeAdmission>;
42
+ timeoutMs: number;
43
+ onRecovery: () => void;
44
+ onPending: () => void;
45
+ onLateAcceptance: () => void;
46
+ taskIDs: readonly string[];
47
+ onUnknown: (cause: unknown) => void;
48
+ }
49
+
50
+ interface AdmissionResult {
51
+ accepted: boolean;
52
+ pending?: boolean;
53
+ ambiguous: boolean;
54
+ turnObserved: boolean;
55
+ }
56
+
57
+ const READBACK_INTERVAL_MS = 2_000;
58
+ const ADMISSION_RECOVERY_WINDOW_MS = 120_000;
59
+
60
+ function record(value: unknown): Record<string, unknown> | undefined {
61
+ return value && typeof value === "object" && !Array.isArray(value)
62
+ ? (value as Record<string, unknown>)
63
+ : undefined;
64
+ }
65
+
66
+ function responseFailure(response: unknown): string | undefined {
67
+ const value = record(response);
68
+ if (value?.error != null)
69
+ return typeof value.error === "string" ? value.error : JSON.stringify(value.error);
70
+ if (value?.success === false)
71
+ return String(value.message ?? "promptAsync returned success=false");
72
+ return undefined;
73
+ }
74
+
75
+ /** A recorded user message must contain the submitted model-visible text, not just its id. */
76
+ function containsSubmittedText(
77
+ value: unknown,
78
+ sessionID: string,
79
+ body: Record<string, unknown>,
80
+ ): boolean {
81
+ const message = record(value);
82
+ const info = record(message?.info);
83
+ if (info?.role !== "user" || info.id !== body.messageID || info.sessionID !== sessionID)
84
+ return false;
85
+ const expected = Array.isArray(body.parts)
86
+ ? body.parts.map(record).filter((part) => part?.type === "text")
87
+ : [];
88
+ const parts = Array.isArray(message?.parts) ? message.parts.map(record) : [];
89
+ return (
90
+ expected.length > 0 &&
91
+ expected.every((part) =>
92
+ parts.some(
93
+ (actual) =>
94
+ actual?.type === "text" &&
95
+ actual.text === part?.text &&
96
+ actual.synthetic === true &&
97
+ actual.ignored !== true,
98
+ ),
99
+ )
100
+ );
101
+ }
102
+
103
+ /** Bound readback independently; a missing or failed read is never proof of rejection. */
104
+ async function readHostMessage(
105
+ options: AdmissionOptions,
106
+ controller: AbortController,
107
+ ): Promise<unknown> {
108
+ const { sessionApi, sessionID, requestBody, timeoutMs } = options;
109
+ let timer: ReturnType<typeof setTimeout> | undefined;
110
+ try {
111
+ const read =
112
+ typeof sessionApi.message === "function"
113
+ ? Promise.resolve().then(() =>
114
+ sessionApi.message?.({
115
+ path: { id: sessionID, messageID: requestBody.messageID },
116
+ signal: controller.signal,
117
+ throwOnError: false,
118
+ responseStyle: "fields",
119
+ }),
120
+ )
121
+ : typeof sessionApi.messages === "function"
122
+ ? Promise.resolve().then(() =>
123
+ sessionApi.messages?.({
124
+ path: { id: sessionID },
125
+ query: { limit: 50 },
126
+ signal: controller.signal,
127
+ throwOnError: false,
128
+ responseStyle: "fields",
129
+ }),
130
+ )
131
+ : Promise.resolve(undefined);
132
+ return await Promise.race([
133
+ read,
134
+ new Promise<undefined>((resolve) => {
135
+ timer = setTimeout(() => {
136
+ controller.abort();
137
+ resolve(undefined);
138
+ }, timeoutMs);
139
+ timer.unref?.();
140
+ }),
141
+ ]);
142
+ } catch {
143
+ return undefined;
144
+ } finally {
145
+ if (timer) clearTimeout(timer);
146
+ controller.abort();
147
+ }
148
+ }
149
+
150
+ /**
151
+ * The V1 SDK's prompt_async HTTP 204 accepts background prompt work before
152
+ * the assistant finishes it. For an uncertain response, check the submitted
153
+ * message's text or assistant child for two minutes. One recovery then reuses
154
+ * the original messageID and body; that identity is not a deduplication promise.
155
+ * Each request has its own timeout budget. If recovery is also unproven, keep
156
+ * the pending wake reminders for the next tool reply/model turn, without a third automatic
157
+ * submission for this ambiguous delivery.
158
+ */
159
+ export async function awaitWakeAdmission(options: AdmissionOptions): Promise<AdmissionResult> {
160
+ let requestController = new AbortController();
161
+ let requestTimer: ReturnType<typeof setTimeout> | undefined;
162
+ let recoveryTimer: ReturnType<typeof setTimeout> | undefined;
163
+ let pollTimer: ReturnType<typeof setTimeout> | undefined;
164
+ let readController: AbortController | undefined;
165
+ let readInFlight: Promise<void> | undefined;
166
+ let readAgain = false;
167
+ let ambiguous = false;
168
+ let recovered = false;
169
+ let completed = false;
170
+ let returnedPending = false;
171
+ let finish!: (result: AdmissionResult) => void;
172
+ let reject!: (error: Error) => void;
173
+ const result = new Promise<AdmissionResult>((resolve, fail) => {
174
+ finish = resolve;
175
+ reject = fail;
176
+ });
177
+ const current = () => !completed && options.pending.get(options.deliveryID) === admission;
178
+ const clearTimers = () => {
179
+ if (requestTimer) clearTimeout(requestTimer);
180
+ if (recoveryTimer) clearTimeout(recoveryTimer);
181
+ if (pollTimer) clearTimeout(pollTimer);
182
+ requestTimer = recoveryTimer = pollTimer = undefined;
183
+ };
184
+ const detach = () => {
185
+ clearTimers();
186
+ requestController.abort();
187
+ readController?.abort();
188
+ if (options.pending.get(options.deliveryID) === admission)
189
+ options.pending.delete(options.deliveryID);
190
+ };
191
+ const accept = (turnObserved = false) => {
192
+ if (!current()) return;
193
+ completed = true;
194
+ detach();
195
+ if (returnedPending) options.onLateAcceptance();
196
+ else finish({ accepted: true, ambiguous, turnObserved });
197
+ };
198
+ const leavePending = () => {
199
+ if (!current() || returnedPending) return;
200
+ returnedPending = true;
201
+ admission.status = "pending";
202
+ clearTimers();
203
+ requestController.abort();
204
+ readController?.abort();
205
+ // Restore queues before resolving, so a host event cannot race their restoration.
206
+ options.onPending();
207
+ finish({ accepted: false, pending: true, ambiguous, turnObserved: false });
208
+ };
209
+ const admission: PendingWakeAdmission = {
210
+ messageID: String(options.requestBody.messageID),
211
+ taskIDs: options.taskIDs,
212
+ status: "submitting",
213
+ observe(info) {
214
+ if (info.sessionID !== options.sessionID) return;
215
+ if (info.role === "assistant" && info.parentID === admission.messageID) accept(true);
216
+ else if (info.role === "user" && info.id === admission.messageID) void admission.reconcile();
217
+ },
218
+ reconcile() {
219
+ if (!current() || !ambiguous) return Promise.resolve();
220
+ if (readInFlight) {
221
+ readAgain = true;
222
+ return readInFlight;
223
+ }
224
+ readController = new AbortController();
225
+ readInFlight = readHostMessage(options, readController)
226
+ .then((response) => {
227
+ const data = record(response)?.data ?? response;
228
+ const messages = Array.isArray(data) ? data : [data];
229
+ if (
230
+ messages.some((message) =>
231
+ containsSubmittedText(message, options.sessionID, options.requestBody),
232
+ )
233
+ )
234
+ accept();
235
+ })
236
+ .catch(() => undefined)
237
+ .finally(() => {
238
+ readInFlight = undefined;
239
+ if (readAgain && current()) {
240
+ readAgain = false;
241
+ void admission.reconcile();
242
+ }
243
+ });
244
+ return readInFlight;
245
+ },
246
+ cancel() {
247
+ if (!current()) return;
248
+ completed = true;
249
+ detach();
250
+ if (!returnedPending) finish({ accepted: false, ambiguous, turnObserved: false });
251
+ },
252
+ };
253
+ options.pending.set(options.deliveryID, admission);
254
+ const poll = () => {
255
+ if (!current() || returnedPending) return;
256
+ void admission.reconcile();
257
+ pollTimer = setTimeout(poll, READBACK_INTERVAL_MS);
258
+ pollTimer.unref?.();
259
+ };
260
+ const unknown = (cause: unknown) => {
261
+ if (!current()) return;
262
+ if (!ambiguous) {
263
+ ambiguous = true;
264
+ admission.status = "unknown";
265
+ if (requestTimer) clearTimeout(requestTimer);
266
+ requestController.abort();
267
+ options.onUnknown(cause);
268
+ poll();
269
+ recoveryTimer = setTimeout(() => {
270
+ recoveryTimer = undefined;
271
+ if (!current() || recovered) return;
272
+ // Set the guard before invoking callbacks or dispatching the recovery request.
273
+ recovered = true;
274
+ options.onRecovery();
275
+ submit();
276
+ }, ADMISSION_RECOVERY_WINDOW_MS);
277
+ recoveryTimer.unref?.();
278
+ } else if (recovered && !returnedPending) {
279
+ leavePending();
280
+ }
281
+ };
282
+ const submit = () => {
283
+ if (!current()) return;
284
+ requestController = new AbortController();
285
+ const controller = requestController;
286
+ if (requestTimer) clearTimeout(requestTimer);
287
+ requestTimer = setTimeout(() => {
288
+ controller.abort();
289
+ unknown(new Error(`session promptAsync injection timed out after ${options.timeoutMs}ms`));
290
+ }, options.timeoutMs);
291
+ requestTimer.unref?.();
292
+ void Promise.resolve()
293
+ .then(() => {
294
+ if (!current() || controller.signal.aborted) return undefined;
295
+ return options.sessionApi.promptAsync({
296
+ path: { id: options.sessionID },
297
+ body: options.requestBody,
298
+ signal: controller.signal,
299
+ throwOnError: false,
300
+ responseStyle: "fields",
301
+ });
302
+ })
303
+ .then(
304
+ (response) => {
305
+ if (!current()) return;
306
+ const failure = responseFailure(response);
307
+ const status = record(record(response)?.response)?.status;
308
+ const error = record(record(response)?.error);
309
+ // These SDK error responses come from request validation or a missing
310
+ // session, which the server checks before scheduling background prompt work.
311
+ const refused =
312
+ (status === 400 && error?.name === "BadRequest") ||
313
+ (status === 404 && error?.name === "NotFoundError");
314
+ if (refused && !recovered && controller === requestController) {
315
+ completed = true;
316
+ detach();
317
+ reject(
318
+ new WakeAdmissionRejected(
319
+ failure ?? `promptAsync rejected (${status})`,
320
+ status as number,
321
+ ),
322
+ );
323
+ } else if (status === 204 || (status === undefined && !failure)) {
324
+ accept();
325
+ } else {
326
+ // A late rejection of recovery does not prove the original was rejected.
327
+ if (controller === requestController)
328
+ unknown(new Error(failure ?? `Unexpected promptAsync status ${status}`));
329
+ }
330
+ },
331
+ (cause) => {
332
+ if (controller !== requestController) return;
333
+ unknown(cause);
334
+ },
335
+ )
336
+ .catch(unknown);
337
+ };
338
+ submit();
339
+ return result;
340
+ }
@@ -49,6 +49,14 @@ const WEB_SOCKET_OPEN = 1;
49
49
  const clients = new Map<string, AftRpcClient>();
50
50
  const statusInvalidationListeners = new Set<(event: StatusInvalidation) => void>();
51
51
  const lastHandledIdBySession = new Map<string, number>();
52
+ const socketEndpoints = new WeakMap<WebSocketLike, AftRpcEndpoint>();
53
+ const CURSOR_CACHE_LIMIT = 200;
54
+
55
+ function cursorKey(ws: WebSocketLike, sessionId: string): string | null {
56
+ const endpoint = socketEndpoints.get(ws);
57
+ // The authentication token distinguishes server instances, even when a new instance reuses the port.
58
+ return endpoint?.token ? `${endpoint.token}\0${sessionId}` : null;
59
+ }
52
60
 
53
61
  function defaultWebSocketCtor(): WebSocketFactory | null {
54
62
  const ctor = (globalThis as typeof globalThis & { WebSocket?: WebSocketFactory }).WebSocket;
@@ -128,6 +136,7 @@ export function stopAftTuiSocket(): void {
128
136
  closeCurrentSocket(false);
129
137
  for (const client of clients.values()) client.reset();
130
138
  clients.clear();
139
+ lastHandledIdBySession.clear();
131
140
  reconnectAttempt = 0;
132
141
  }
133
142
 
@@ -250,6 +259,7 @@ async function connect(scope: SocketScope, connectGeneration: number): Promise<v
250
259
  }
251
260
 
252
261
  socket = ws;
262
+ socketEndpoints.set(ws, endpoint);
253
263
  socketScope = scope;
254
264
  helloedScope = null;
255
265
 
@@ -335,13 +345,21 @@ async function sendHelloWithFreshToken(
335
345
  ) {
336
346
  return;
337
347
  }
338
- sendHello(ws, scope, endpoint?.token ?? null);
348
+ const connected = socketEndpoints.get(ws);
349
+ if (!endpoint || endpoint.port !== connected?.port || endpoint.token !== connected.token) {
350
+ generation += 1;
351
+ closeCurrentSocket(false);
352
+ void connect(scope, generation);
353
+ return;
354
+ }
355
+ sendHello(ws, scope, connected.token);
339
356
  }
340
357
 
341
358
  function sendHello(ws: WebSocketLike, scope: SocketScope, token: string | null): void {
342
359
  helloedScope = scope;
343
360
  if (socket === ws) socketScope = scope;
344
- const lastReceivedId = lastHandledIdBySession.get(scope.sessionId) ?? 0;
361
+ const key = cursorKey(ws, scope.sessionId);
362
+ const lastReceivedId = key ? (lastHandledIdBySession.get(key) ?? 0) : 0;
345
363
  ws.send(
346
364
  JSON.stringify({
347
365
  type: "hello",
@@ -400,8 +418,16 @@ async function handleSocketMessage(
400
418
  ) {
401
419
  return;
402
420
  }
403
- if (consumed && notification.id > (lastHandledIdBySession.get(active) ?? 0)) {
404
- lastHandledIdBySession.set(active, notification.id);
421
+ const key = cursorKey(ws, active);
422
+ if (consumed && notification.id > (key ? (lastHandledIdBySession.get(key) ?? 0) : 0)) {
423
+ if (key) {
424
+ lastHandledIdBySession.delete(key);
425
+ lastHandledIdBySession.set(key, notification.id);
426
+ if (lastHandledIdBySession.size > CURSOR_CACHE_LIMIT) {
427
+ const oldest = lastHandledIdBySession.keys().next().value;
428
+ if (oldest !== undefined) lastHandledIdBySession.delete(oldest);
429
+ }
430
+ }
405
431
  try {
406
432
  ws.send(JSON.stringify({ type: "ack", lastReceivedId: notification.id }));
407
433
  } catch {
@@ -39,6 +39,12 @@ export interface AftTuiPrefs {
39
39
  startCollapsed: boolean;
40
40
  rememberCollapsed: boolean;
41
41
  collapsed: boolean | null;
42
+ /**
43
+ * Whether OpenCode 2 shows AFT's health line (`AFT E0 W0 | D0 U0 C0 | T0`)
44
+ * in the prompt footer. Off hides it entirely; the sidebar and the status
45
+ * dialog are unaffected.
46
+ */
47
+ footer: boolean;
42
48
  header: {
43
49
  label: string;
44
50
  showVersion: boolean;
@@ -57,6 +63,7 @@ export const DEFAULT_PREFS: AftTuiPrefs = {
57
63
  startCollapsed: false,
58
64
  rememberCollapsed: true,
59
65
  collapsed: null,
66
+ footer: true,
60
67
  header: { label: "AFT", showVersion: true },
61
68
  sections: {
62
69
  searchIndex: true,
@@ -99,6 +106,7 @@ export function resolveAftPrefs(root: Record<string, unknown>): AftTuiPrefs {
99
106
  startCollapsed: bool(entry.startCollapsed, d.startCollapsed),
100
107
  rememberCollapsed: bool(entry.rememberCollapsed, d.rememberCollapsed),
101
108
  collapsed: typeof entry.collapsed === "boolean" ? entry.collapsed : null,
109
+ footer: bool(entry.footer, d.footer),
102
110
  header: {
103
111
  label: label(header.label, d.header.label, 20),
104
112
  showVersion: bool(header.showVersion, d.header.showVersion),
@@ -161,18 +169,14 @@ async function writePreference(pluginKey: string, path: string[], value: JsonVal
161
169
  let text: string;
162
170
  try {
163
171
  text = await readFile(file, "utf8");
164
- } catch {
165
- text = "";
166
- }
167
- if (text.trim() === "") text = TEMPLATE;
168
-
169
- let root: Record<string, unknown>;
170
- try {
171
- const parsed = parse(text);
172
- root = isRecord(parsed) ? (parsed as Record<string, unknown>) : {};
173
- } catch {
174
- root = {};
172
+ } catch (error) {
173
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
174
+ text = TEMPLATE;
175
175
  }
176
+ // Only a missing file can be initialized. Invalid existing bytes may belong to another plugin.
177
+ const parsed: unknown = parse(text);
178
+ if (!isRecord(parsed)) throw new Error("TUI preferences must contain an object");
179
+ const root = parsed;
176
180
 
177
181
  if (!isRecord(root[pluginKey])) {
178
182
  root[pluginKey] = {};
@@ -193,22 +197,52 @@ export function queueTuiPreferenceUpdate(
193
197
  path: string[],
194
198
  value: JsonValue,
195
199
  ): Promise<void> {
196
- writeChain = writeChain.then(() => writePreference(pluginKey, path, value)).catch(() => {});
197
- return writeChain;
200
+ const update = writeChain.then(() => writePreference(pluginKey, path, value));
201
+ // Keep subsequent writes usable while returning the actual failure to this caller.
202
+ writeChain = update.catch(() => {});
203
+ return update;
198
204
  }
199
205
 
200
206
  const WATCH_DEBOUNCE_MS = 150;
201
207
 
208
+ /**
209
+ * What the watcher last saw where the preferences file should be: its text,
210
+ * or FILE_ABSENT once a read has confirmed the file does not exist. A symbol
211
+ * can never equal any file text, so deleting the file always counts as a
212
+ * change, even from an empty file.
213
+ */
214
+ const FILE_ABSENT: unique symbol = Symbol("tui-preferences file absent");
215
+ type WatchedState = string | typeof FILE_ABSENT;
216
+
217
+ /**
218
+ * Read the file for the watcher. Returns FILE_ABSENT only for ENOENT; any
219
+ * other failure (a permission error, a directory at the path) returns null,
220
+ * meaning "unknown", so a file that is present but momentarily unreadable
221
+ * never makes callers fall back to defaults.
222
+ */
223
+ async function readWatchedState(file: string): Promise<WatchedState | null> {
224
+ try {
225
+ return await readFile(file, "utf8");
226
+ } catch (error) {
227
+ return (error as NodeJS.ErrnoException).code === "ENOENT" ? FILE_ABSENT : null;
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Calls `onChange` after the preferences file's content changes, including
233
+ * when the file is deleted (callers then read defaults) and when it is
234
+ * created again.
235
+ */
202
236
  export function watchTuiPreferences(onChange: () => void): () => void {
203
237
  const file = getTuiPreferencesFile();
204
238
  const name = basename(file);
205
239
  let timer: ReturnType<typeof setTimeout> | null = null;
206
- let lastSeen: string | null = null;
207
- void readFile(file, "utf8")
208
- .then((text) => {
209
- if (lastSeen === null) lastSeen = text;
210
- })
211
- .catch(() => {});
240
+ // null until a read has succeeded or confirmed the file is missing.
241
+ let lastSeen: WatchedState | null = null;
242
+ let stopped = false;
243
+ void readWatchedState(file).then((state) => {
244
+ if (lastSeen === null && state !== null) lastSeen = state;
245
+ });
212
246
  try {
213
247
  const watcher = watch(dirname(file), (_event, filename) => {
214
248
  const isOurs =
@@ -217,20 +251,23 @@ export function watchTuiPreferences(onChange: () => void): () => void {
217
251
  if (timer) clearTimeout(timer);
218
252
  timer = setTimeout(() => {
219
253
  timer = null;
220
- void readFile(file, "utf8")
221
- .catch(() => null)
222
- .then((text) => {
223
- if (text === null) return;
224
- if (text === lastSeen) return;
225
- lastSeen = text;
226
- onChange();
227
- });
254
+ void readWatchedState(file).then((state) => {
255
+ if (stopped || state === null) return;
256
+ if (state === lastSeen) return;
257
+ lastSeen = state;
258
+ onChange();
259
+ });
228
260
  }, WATCH_DEBOUNCE_MS);
229
261
  });
230
- return () => {
262
+ const stop = () => {
263
+ if (stopped) return;
264
+ stopped = true;
231
265
  if (timer) clearTimeout(timer);
266
+ timer = null;
232
267
  watcher.close();
233
268
  };
269
+ watcher.on("error", stop);
270
+ return stop;
234
271
  } catch {
235
272
  return () => {};
236
273
  }
@@ -238,6 +275,6 @@ export function watchTuiPreferences(onChange: () => void): () => void {
238
275
 
239
276
  export function persistCollapsedIfEnabled(prefs: AftTuiPrefs, collapsed: boolean): void {
240
277
  if (prefs.rememberCollapsed) {
241
- void queueTuiPreferenceUpdate(PLUGIN_KEY, ["collapsed"], collapsed);
278
+ void queueTuiPreferenceUpdate(PLUGIN_KEY, ["collapsed"], collapsed).catch(() => {});
242
279
  }
243
280
  }
@@ -413,6 +413,7 @@ export const AftSidebarPanel = (props: AftSidebarPanelProps) => {
413
413
  error: s()?.semantic_index?.error,
414
414
  reason: s()?.semantic_index?.reason,
415
415
  backend_url: s()?.semantic_index?.backend_url,
416
+ pending_reason: s()?.semantic_index?.pending_reason,
416
417
  }),
417
418
  };
418
419
  };
package/src/tui/v2.tsx CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  formatStatusDialogMessage,
10
10
  } from "../shared/status";
11
11
  import { AftRpc } from "./aft-rpc";
12
+ import { readTuiPreferencesFile, resolveAftPrefs, watchTuiPreferences } from "./preferences";
12
13
  import {
13
14
  AftSidebarPanel,
14
15
  createSidebarPreferences,
@@ -250,11 +251,58 @@ function useAftStatus(
250
251
  return status;
251
252
  }
252
253
 
253
- function FooterStatus(props: { context: V2TuiContext; rpc: AftRpcClient; sessionID?: string }) {
254
+ /**
255
+ * The `footer` switch from the `aft` entry of `tui-preferences.jsonc`, kept
256
+ * current while the file is edited, as the sidebar's preferences are.
257
+ *
258
+ * It reads as hidden until the file has been read once. Starting from the
259
+ * default (shown) instead would flash the line on every prompt mount for
260
+ * someone who turned it off; the read takes milliseconds, and a missing or
261
+ * unreadable file resolves to the default, so the line still appears for
262
+ * everyone else.
263
+ */
264
+ function createFooterVisibility(): () => boolean {
265
+ const [visible, setVisible] = createSignal(false);
266
+ // Reads can overlap when the file changes twice in quick succession; only
267
+ // the most recently started read may set the value.
268
+ let generation = 0;
269
+ const reload = async (): Promise<void> => {
270
+ const current = ++generation;
271
+ const prefs = resolveAftPrefs(await readTuiPreferencesFile());
272
+ if (current === generation) setVisible(prefs.footer);
273
+ };
274
+ void reload();
275
+ const unwatch = watchTuiPreferences(() => {
276
+ void reload();
277
+ });
278
+ onCleanup(() => {
279
+ generation += 1;
280
+ unwatch();
281
+ });
282
+ return visible;
283
+ }
284
+
285
+ function FooterSegment(props: { context: V2TuiContext; rpc: AftRpcClient; sessionID?: string }) {
254
286
  const status = useAftStatus(props.context, props.rpc, () => props.sessionID);
255
287
  return <text>{formatAftStatusSegment(status())}</text>;
256
288
  }
257
289
 
290
+ /**
291
+ * The prompt footer claim. With `footer: false` it renders no element at all,
292
+ * so the line takes no space, and the status subscription behind it is torn
293
+ * down until the switch is turned back on.
294
+ */
295
+ function FooterStatus(props: { context: V2TuiContext; rpc: AftRpcClient; sessionID?: string }) {
296
+ const visible = createFooterVisibility();
297
+ return (
298
+ <>
299
+ {visible() ? (
300
+ <FooterSegment context={props.context} rpc={props.rpc} sessionID={props.sessionID} />
301
+ ) : null}
302
+ </>
303
+ );
304
+ }
305
+
258
306
  function SidebarStatus(props: { context: V2TuiContext; rpc: AftRpcClient; sessionID: string }) {
259
307
  const status = useAftStatus(props.context, props.rpc, () => props.sessionID);
260
308
  const preferences = createSidebarPreferences();