@cortexkit/aft-opencode 0.62.0 → 0.63.4
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/bash-wait-detach.d.ts +1 -0
- package/dist/bash-wait-detach.d.ts.map +1 -1
- package/dist/bg-notifications.d.ts +30 -11
- package/dist/bg-notifications.d.ts.map +1 -1
- package/dist/bridge-bootstrap.d.ts +1 -1
- package/dist/bridge-bootstrap.d.ts.map +1 -1
- package/dist/config-live-reload.d.ts +14 -2
- package/dist/config-live-reload.d.ts.map +1 -1
- package/dist/config.d.ts +37 -8
- package/dist/config.d.ts.map +1 -1
- package/dist/entry/server.js +4015 -1382
- package/dist/hooks/auto-update-checker/index.d.ts +5 -1
- package/dist/hooks/auto-update-checker/index.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3294 -1243
- package/dist/logger.d.ts +6 -10
- package/dist/logger.d.ts.map +1 -1
- package/dist/lsp-auto-install.d.ts.map +1 -1
- package/dist/lsp.d.ts +1 -0
- package/dist/lsp.d.ts.map +1 -1
- package/dist/shared/rpc-client.d.ts.map +1 -1
- package/dist/shared/rpc-notifications.d.ts.map +1 -1
- package/dist/shared/rpc-server.d.ts +2 -0
- package/dist/shared/rpc-server.d.ts.map +1 -1
- package/dist/shared/session-cleanup.d.ts +6 -0
- package/dist/shared/session-cleanup.d.ts.map +1 -0
- package/dist/shared/session-directory.d.ts +24 -0
- package/dist/shared/session-directory.d.ts.map +1 -1
- package/dist/shared/status.d.ts +12 -1
- package/dist/shared/status.d.ts.map +1 -1
- package/dist/shared/subagent-detect.d.ts +24 -8
- package/dist/shared/subagent-detect.d.ts.map +1 -1
- package/dist/shared/wake-admission.d.ts +68 -0
- package/dist/shared/wake-admission.d.ts.map +1 -0
- package/dist/shutdown-hooks.d.ts +2 -0
- package/dist/shutdown-hooks.d.ts.map +1 -1
- package/dist/subc-tool-schemas.d.ts.map +1 -1
- package/dist/tools/_shared.d.ts +5 -1
- package/dist/tools/_shared.d.ts.map +1 -1
- package/dist/tools/bash.d.ts +1 -0
- package/dist/tools/bash.d.ts.map +1 -1
- package/dist/tools/bash_watch.d.ts +4 -0
- package/dist/tools/bash_watch.d.ts.map +1 -1
- package/dist/tools/definitions/v2.d.ts +2 -1
- package/dist/tools/definitions/v2.d.ts.map +1 -1
- package/dist/tools/hoisted.d.ts.map +1 -1
- package/dist/tools/inspect.d.ts.map +1 -1
- package/dist/tools/metadata.d.ts +7 -0
- package/dist/tools/metadata.d.ts.map +1 -0
- package/dist/tools/outside-permissions.d.ts +35 -0
- package/dist/tools/outside-permissions.d.ts.map +1 -0
- package/dist/tools/permissions.d.ts +9 -11
- package/dist/tools/permissions.d.ts.map +1 -1
- package/dist/tools/reading.d.ts.map +1 -1
- package/dist/tools/search.d.ts.map +1 -1
- package/dist/tools/semantic.d.ts.map +1 -1
- package/dist/tui/notification-socket.d.ts.map +1 -1
- package/dist/tui/preferences.d.ts +11 -0
- package/dist/tui/preferences.d.ts.map +1 -1
- package/dist/v2-session-lifecycle.d.ts +7 -0
- package/dist/v2-session-lifecycle.d.ts.map +1 -1
- package/dist/v2-tool-hooks.d.ts.map +1 -1
- package/dist/wakes/runtime-consumer.d.ts +9 -3
- package/dist/wakes/runtime-consumer.d.ts.map +1 -1
- package/dist/wakes/session-delivery.d.ts +22 -4
- package/dist/wakes/session-delivery.d.ts.map +1 -1
- package/package.json +8 -8
- package/src/entry/server-runtime.mjs +53 -18
- package/src/logger.ts +12 -5
- package/src/shared/rpc-client.ts +35 -28
- package/src/shared/rpc-notifications.ts +5 -3
- package/src/shared/rpc-server.ts +65 -7
- package/src/shared/session-cleanup.ts +13 -0
- package/src/shared/session-directory.ts +59 -3
- package/src/shared/status.ts +32 -2
- package/src/shared/subagent-detect.ts +53 -25
- package/src/shared/wake-admission.ts +340 -0
- package/src/tui/notification-socket.ts +30 -4
- package/src/tui/preferences.ts +66 -29
- package/src/tui/sidebar-view.tsx +1 -0
- package/src/tui/v2.tsx +49 -1
- package/src/tui-compiled/notification-socket.ts +30 -4
- package/src/tui-compiled/preferences.ts +66 -29
- package/src/tui-compiled/sidebar-view.tsx +2 -1
- 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
|
-
|
|
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
|
|
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
|
-
|
|
404
|
-
|
|
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 {
|
package/src/tui/preferences.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/tui/sidebar-view.tsx
CHANGED
|
@@ -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
|
-
|
|
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();
|