@cortexkit/aft-opencode 0.42.0 → 0.43.1

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 (59) hide show
  1. package/dist/bg-notifications.d.ts +35 -0
  2. package/dist/bg-notifications.d.ts.map +1 -1
  3. package/dist/config.d.ts +7 -3
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/configure-warnings.d.ts +3 -3
  6. package/dist/configure-warnings.d.ts.map +1 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +6092 -9528
  9. package/dist/notifications.d.ts +2 -2
  10. package/dist/notifications.d.ts.map +1 -1
  11. package/dist/shared/rpc-client.d.ts +6 -0
  12. package/dist/shared/rpc-client.d.ts.map +1 -1
  13. package/dist/shared/rpc-notifications.d.ts +29 -15
  14. package/dist/shared/rpc-notifications.d.ts.map +1 -1
  15. package/dist/shared/rpc-server.d.ts +10 -1
  16. package/dist/shared/rpc-server.d.ts.map +1 -1
  17. package/dist/subc-tool-schemas.d.ts +1 -1
  18. package/dist/subc-tool-schemas.d.ts.map +1 -1
  19. package/dist/tools/_shared.d.ts +12 -2
  20. package/dist/tools/_shared.d.ts.map +1 -1
  21. package/dist/tools/ast.d.ts.map +1 -1
  22. package/dist/tools/bash.d.ts.map +1 -1
  23. package/dist/tools/bash_watch.d.ts.map +1 -1
  24. package/dist/tools/hoisted.d.ts.map +1 -1
  25. package/dist/tools/imports.d.ts.map +1 -1
  26. package/dist/tools/inspect.d.ts +0 -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 +23 -13
  30. package/dist/tools/permissions.d.ts.map +1 -1
  31. package/dist/tools/reading.d.ts +0 -1
  32. package/dist/tools/reading.d.ts.map +1 -1
  33. package/dist/tools/refactoring.d.ts.map +1 -1
  34. package/dist/tools/safety.d.ts.map +1 -1
  35. package/dist/tools/search.d.ts.map +1 -1
  36. package/dist/tools/semantic.d.ts.map +1 -1
  37. package/dist/tui/notification-socket.d.ts +41 -0
  38. package/dist/tui/notification-socket.d.ts.map +1 -0
  39. package/dist/tui.js +2460 -187
  40. package/dist/types.d.ts +4 -2
  41. package/dist/types.d.ts.map +1 -1
  42. package/package.json +8 -9
  43. package/src/shared/rpc-client.ts +9 -0
  44. package/src/shared/rpc-notifications.ts +97 -41
  45. package/src/shared/rpc-server.ts +286 -67
  46. package/src/tui/index.tsx +77 -125
  47. package/src/tui/notification-socket.ts +421 -0
  48. package/src/tui/sidebar.tsx +38 -54
  49. package/dist/patch-parser.d.ts +0 -33
  50. package/dist/patch-parser.d.ts.map +0 -1
  51. package/dist/shared/opencode-config-dir.d.ts +0 -16
  52. package/dist/shared/opencode-config-dir.d.ts.map +0 -1
  53. package/dist/shared/pty-cache.d.ts +0 -18
  54. package/dist/shared/pty-cache.d.ts.map +0 -1
  55. package/dist/shared/tui-config.d.ts +0 -2
  56. package/dist/shared/tui-config.d.ts.map +0 -1
  57. package/src/shared/opencode-config-dir.ts +0 -46
  58. package/src/shared/pty-cache.ts +0 -113
  59. package/src/shared/tui-config.ts +0 -58
@@ -1,4 +1,4 @@
1
- import { randomBytes } from "node:crypto";
1
+ import { randomBytes, timingSafeEqual } from "node:crypto";
2
2
  import {
3
3
  existsSync,
4
4
  mkdirSync,
@@ -11,22 +11,92 @@ import {
11
11
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
12
12
  import { dirname, join } from "node:path";
13
13
  import { log, warn } from "../logger";
14
+ import {
15
+ drainNotifications,
16
+ type NotificationSink,
17
+ registerNotificationSink,
18
+ registerStatusChangeSink,
19
+ type StatusChangeSink,
20
+ } from "./rpc-notifications";
14
21
  import { isPidAlive, parseRpcPortRecord, rpcPortFileDir } from "./rpc-utils";
15
22
 
16
23
  type RpcHandler = (params: Record<string, unknown>) => Promise<Record<string, unknown>>;
17
24
 
25
+ type BunServe = <Data>(options: BunServeOptions<Data>) => BunServer<Data>;
26
+
27
+ interface BunRuntime {
28
+ serve: BunServe;
29
+ }
30
+
31
+ interface BunServer<Data> {
32
+ port?: number;
33
+ stop(closeActiveConnections?: boolean): void;
34
+ upgrade(req: Request, options: { data: Data }): boolean;
35
+ }
36
+
37
+ interface BunServeOptions<Data> {
38
+ port: number;
39
+ hostname: string;
40
+ fetch(
41
+ req: Request,
42
+ server: BunServer<Data>,
43
+ ): Response | Promise<Response | undefined> | undefined;
44
+ websocket: {
45
+ open(ws: BunServerWebSocket<Data>): void;
46
+ message(ws: BunServerWebSocket<Data>, raw: string | Buffer): void;
47
+ close(ws: BunServerWebSocket<Data>): void;
48
+ };
49
+ }
50
+
51
+ interface BunServerWebSocket<Data> {
52
+ data: Data;
53
+ send(data: string): unknown;
54
+ close(code?: number, reason?: string): void;
55
+ }
56
+
57
+ interface WsData {
58
+ authed: boolean;
59
+ sessionId?: string;
60
+ unregisterNotification?: () => void;
61
+ unregisterStatus?: () => void;
62
+ authTimer?: ReturnType<typeof setTimeout>;
63
+ }
64
+
18
65
  const PORT_FILE_HEARTBEAT_MS = 15_000;
66
+ const MAX_BODY_BYTES = 1_048_576;
67
+ const WS_AUTH_TIMEOUT_MS = 5_000;
68
+ const WS_CLOSE_UNAUTHORIZED = 4401;
69
+
70
+ function bunRuntime(): BunRuntime | undefined {
71
+ return (globalThis as typeof globalThis & { Bun?: BunRuntime }).Bun;
72
+ }
73
+
74
+ function tokensMatch(presented: string, expected: string): boolean {
75
+ const a = Buffer.from(presented, "utf8");
76
+ const b = Buffer.from(expected, "utf8");
77
+ if (a.length !== b.length) return false;
78
+ return timingSafeEqual(a, b);
79
+ }
80
+
81
+ function json(body: unknown, status = 200): Response {
82
+ return new Response(JSON.stringify(body), {
83
+ status,
84
+ headers: { "Content-Type": "application/json" },
85
+ });
86
+ }
19
87
 
20
88
  export class AftRpcServer {
21
- private server: Server | null = null;
89
+ private nodeServer: Server | null = null;
90
+ private bunServer: BunServer<WsData> | null = null;
22
91
  private port = 0;
23
- private token: string | null = null;
92
+ private token = randomBytes(32).toString("hex");
24
93
  private handlers = new Map<string, RpcHandler>();
25
94
  private portFilePath: string;
26
95
  private portsDir: string;
27
96
  private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
28
97
  /** Unique per-instance ID — distinguishes our entry from duplicate plugin loads. */
29
98
  private instanceId: string;
99
+ private sockets = new Set<BunServerWebSocket<WsData>>();
30
100
 
31
101
  constructor(storageDir: string, directory: string) {
32
102
  this.portsDir = rpcPortFileDir(storageDir, directory);
@@ -41,6 +111,36 @@ export class AftRpcServer {
41
111
 
42
112
  /** Start the server on a random port, write port to disk. */
43
113
  async start(): Promise<number> {
114
+ const bun = bunRuntime();
115
+ if (bun) {
116
+ return this.startBun(bun);
117
+ }
118
+ return this.startNode();
119
+ }
120
+
121
+ private async startBun(bun: BunRuntime): Promise<number> {
122
+ const server = bun.serve<WsData>({
123
+ port: 0,
124
+ hostname: "127.0.0.1",
125
+ fetch: (req, srv) => this.handleFetch(req, srv),
126
+ websocket: {
127
+ open: (ws) => {
128
+ ws.data.authTimer = setTimeout(() => {
129
+ if (!ws.data.authed) ws.close(WS_CLOSE_UNAUTHORIZED, "auth timeout");
130
+ }, WS_AUTH_TIMEOUT_MS);
131
+ },
132
+ message: (ws, raw) => this.handleWsMessage(ws, raw),
133
+ close: (ws) => this.closeWs(ws),
134
+ },
135
+ });
136
+
137
+ this.bunServer = server;
138
+ this.port = server.port ?? 0;
139
+ this.afterServerStarted();
140
+ return this.port;
141
+ }
142
+
143
+ private async startNode(): Promise<number> {
44
144
  return new Promise((resolve, reject) => {
45
145
  const server = createServer((req, res) => this.dispatch(req, res));
46
146
 
@@ -56,48 +156,8 @@ export class AftRpcServer {
56
156
  return;
57
157
  }
58
158
  this.port = addr.port;
59
- this.token = randomBytes(32).toString("hex");
60
- this.server = server;
61
-
62
- // Write port file atomically
63
- try {
64
- const dir = dirname(this.portFilePath);
65
- mkdirSync(dir, { recursive: true, mode: 0o700 });
66
- const tmpPath = `${this.portFilePath}.tmp`;
67
- // Record pid + started_at so the client can skip files whose owning
68
- // process is dead (without a health-check round-trip) and prefer the
69
- // freshest live server, instead of accumulating crash/restart leftovers.
70
- writeFileSync(
71
- tmpPath,
72
- JSON.stringify({
73
- port: this.port,
74
- token: this.token,
75
- pid: process.pid,
76
- started_at: Date.now(),
77
- }),
78
- { encoding: "utf-8", mode: 0o600 },
79
- );
80
- renameSync(tmpPath, this.portFilePath);
81
- log(`RPC server listening on 127.0.0.1:${this.port}`);
82
- // Self-heal: the port file is this server's only discoverability
83
- // record, and historically it was written exactly once — anything
84
- // that deleted it (a client misjudging a load-induced health-check
85
- // timeout as staleness, cache cleanup, manual sweeps) silently
86
- // orphaned the server until host restart (issue #110: sidebar stuck
87
- // on the lazy placeholder forever). Recreate it if it goes missing.
88
- this.heartbeatTimer = setInterval(() => this.ensurePortFile(), PORT_FILE_HEARTBEAT_MS);
89
- this.heartbeatTimer.unref?.();
90
- // Hygiene: sweep dead siblings while we're here. The client only
91
- // reclaims dead-pid files on a cold port scan, and it caches the
92
- // first warm port afterwards — so crash/restart leftovers piled up
93
- // (dozens of dead files per project hash were observed). Server
94
- // startup is the natural sweep point: it runs once per instance and
95
- // already owns this directory.
96
- this.sweepDeadPortFiles();
97
- } catch (err) {
98
- warn(`Failed to write RPC port file: ${err}`);
99
- }
100
-
159
+ this.nodeServer = server;
160
+ this.afterServerStarted();
101
161
  resolve(this.port);
102
162
  });
103
163
 
@@ -106,6 +166,40 @@ export class AftRpcServer {
106
166
  });
107
167
  }
108
168
 
169
+ private afterServerStarted(): void {
170
+ try {
171
+ this.writePortFile();
172
+ log(`RPC server listening on 127.0.0.1:${this.port}`);
173
+ // Self-heal: the port file is this server's only discoverability record.
174
+ // Anything that deletes it silently orphans the server until host restart.
175
+ // Recreate it if missing.
176
+ this.heartbeatTimer = setInterval(() => this.ensurePortFile(), PORT_FILE_HEARTBEAT_MS);
177
+ this.heartbeatTimer.unref?.();
178
+ // Hygiene: sweep dead port files from other instances while we're here.
179
+ // Server startup is the natural sweep point and already owns this directory.
180
+ this.sweepDeadPortFiles();
181
+ } catch (err) {
182
+ warn(`Failed to write RPC port file: ${err}`);
183
+ }
184
+ }
185
+
186
+ private writePortFile(): void {
187
+ const dir = dirname(this.portFilePath);
188
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
189
+ const tmpPath = `${this.portFilePath}.tmp`;
190
+ writeFileSync(
191
+ tmpPath,
192
+ JSON.stringify({
193
+ port: this.port,
194
+ token: this.token,
195
+ pid: process.pid,
196
+ started_at: Date.now(),
197
+ }),
198
+ { encoding: "utf-8", mode: 0o600 },
199
+ );
200
+ renameSync(tmpPath, this.portFilePath);
201
+ }
202
+
109
203
  /**
110
204
  * Remove sibling port files whose owning process is provably dead.
111
205
  * Only files with a recorded pid that no longer maps to a live process are
@@ -143,21 +237,10 @@ export class AftRpcServer {
143
237
 
144
238
  /** Rewrite the port file if it disappeared (wrongful deletion recovery). */
145
239
  private ensurePortFile(): void {
146
- if (!this.server || this.token === null) return;
240
+ if ((!this.nodeServer && !this.bunServer) || this.port <= 0) return;
147
241
  try {
148
242
  if (existsSync(this.portFilePath)) return;
149
- const tmpPath = `${this.portFilePath}.tmp`;
150
- writeFileSync(
151
- tmpPath,
152
- JSON.stringify({
153
- port: this.port,
154
- token: this.token,
155
- pid: process.pid,
156
- started_at: Date.now(),
157
- }),
158
- { encoding: "utf-8", mode: 0o600 },
159
- );
160
- renameSync(tmpPath, this.portFilePath);
243
+ this.writePortFile();
161
244
  log(`RPC port file was missing; rewrote ${this.portFilePath}`);
162
245
  } catch {
163
246
  // best-effort; retried on the next heartbeat
@@ -170,11 +253,23 @@ export class AftRpcServer {
170
253
  clearInterval(this.heartbeatTimer);
171
254
  this.heartbeatTimer = null;
172
255
  }
173
- if (this.server) {
174
- this.server.close();
175
- this.server = null;
256
+ for (const ws of this.sockets) {
257
+ try {
258
+ this.closeWs(ws);
259
+ ws.close();
260
+ } catch {
261
+ // Ignore close errors during shutdown; remaining sockets are closed independently.
262
+ }
263
+ }
264
+ this.sockets.clear();
265
+ if (this.nodeServer) {
266
+ this.nodeServer.close();
267
+ this.nodeServer = null;
268
+ }
269
+ if (this.bunServer) {
270
+ this.bunServer.stop(true);
271
+ this.bunServer = null;
176
272
  }
177
- this.token = null;
178
273
  try {
179
274
  unlinkSync(this.portFilePath);
180
275
  } catch {
@@ -182,6 +277,132 @@ export class AftRpcServer {
182
277
  }
183
278
  }
184
279
 
280
+ private async handleFetch(req: Request, srv: BunServer<WsData>): Promise<Response | undefined> {
281
+ const url = new URL(req.url);
282
+
283
+ if (url.pathname === "/ws") {
284
+ const upgraded = srv.upgrade(req, { data: { authed: false } });
285
+ if (upgraded) return undefined;
286
+ return new Response("upgrade failed", { status: 400 });
287
+ }
288
+
289
+ if (req.method === "GET" && url.pathname === "/health") {
290
+ return json({ ok: true, pid: process.pid });
291
+ }
292
+
293
+ if (req.method !== "POST" || !url.pathname.startsWith("/rpc/")) {
294
+ return new Response("Not Found", { status: 404 });
295
+ }
296
+
297
+ const method = url.pathname.slice(5);
298
+ const handler = this.handlers.get(method);
299
+ if (!handler) {
300
+ return json({ error: `Unknown method: ${method}` }, 404);
301
+ }
302
+
303
+ const bodyText = await req.text();
304
+ if (bodyText.length > MAX_BODY_BYTES) {
305
+ return new Response("Request too large", { status: 413 });
306
+ }
307
+
308
+ let params: Record<string, unknown> = {};
309
+ try {
310
+ if (bodyText.length > 0) {
311
+ params = JSON.parse(bodyText);
312
+ }
313
+ } catch {
314
+ return json({ error: "Invalid JSON" }, 400);
315
+ }
316
+
317
+ if (!tokensMatch(typeof params.token === "string" ? params.token : "", this.token)) {
318
+ return json({ error: "Forbidden" }, 403);
319
+ }
320
+
321
+ const { token: _token, ...handlerParams } = params;
322
+
323
+ try {
324
+ const result = await handler(handlerParams);
325
+ return json(result);
326
+ } catch (err) {
327
+ log(`RPC error: ${method} => ${err}`);
328
+ return json({ error: String(err) }, 500);
329
+ }
330
+ }
331
+
332
+ private handleWsMessage(ws: BunServerWebSocket<WsData>, raw: string | Buffer): void {
333
+ let msg: { type?: string; token?: string; sessionId?: string; lastReceivedId?: number };
334
+ try {
335
+ msg = JSON.parse(typeof raw === "string" ? raw : raw.toString("utf8"));
336
+ } catch {
337
+ return;
338
+ }
339
+
340
+ if (msg.type === "hello") {
341
+ if (!tokensMatch(typeof msg.token === "string" ? msg.token : "", this.token)) {
342
+ ws.send(JSON.stringify({ type: "error", error: "unauthorized" }));
343
+ ws.close(WS_CLOSE_UNAUTHORIZED, "bad token");
344
+ return;
345
+ }
346
+ if (ws.data.authTimer) {
347
+ clearTimeout(ws.data.authTimer);
348
+ ws.data.authTimer = undefined;
349
+ }
350
+
351
+ ws.data.unregisterNotification?.();
352
+ ws.data.unregisterStatus?.();
353
+ ws.data.authed = true;
354
+ ws.data.sessionId =
355
+ typeof msg.sessionId === "string" && msg.sessionId.length > 0 ? msg.sessionId : undefined;
356
+
357
+ const lastReceivedId = Number(msg.lastReceivedId ?? 0);
358
+ const backlog = drainNotifications(
359
+ Number.isFinite(lastReceivedId) ? lastReceivedId : 0,
360
+ ws.data.sessionId,
361
+ );
362
+
363
+ const notificationSink: NotificationSink = {
364
+ sessionId: ws.data.sessionId,
365
+ send: (notification) => {
366
+ ws.send(JSON.stringify({ type: "notification", notification }));
367
+ },
368
+ };
369
+ const statusSink: StatusChangeSink = {
370
+ sessionId: ws.data.sessionId,
371
+ send: (event) => {
372
+ ws.send(JSON.stringify({ type: "status-changed", ...event }));
373
+ },
374
+ };
375
+ ws.data.unregisterNotification = registerNotificationSink(notificationSink);
376
+ ws.data.unregisterStatus = registerStatusChangeSink(statusSink);
377
+ this.sockets.add(ws);
378
+
379
+ for (const notification of backlog) {
380
+ ws.send(JSON.stringify({ type: "notification", notification }));
381
+ }
382
+ ws.send(JSON.stringify({ type: "hello-ack" }));
383
+ return;
384
+ }
385
+
386
+ if (msg.type === "ack") {
387
+ const lastReceivedId = Number(msg.lastReceivedId ?? 0);
388
+ if (Number.isFinite(lastReceivedId) && lastReceivedId > 0) {
389
+ drainNotifications(lastReceivedId, ws.data.sessionId);
390
+ }
391
+ }
392
+ }
393
+
394
+ private closeWs(ws: BunServerWebSocket<WsData>): void {
395
+ if (ws.data.authTimer) {
396
+ clearTimeout(ws.data.authTimer);
397
+ ws.data.authTimer = undefined;
398
+ }
399
+ ws.data.unregisterNotification?.();
400
+ ws.data.unregisterNotification = undefined;
401
+ ws.data.unregisterStatus?.();
402
+ ws.data.unregisterStatus = undefined;
403
+ this.sockets.delete(ws);
404
+ }
405
+
185
406
  private dispatch(req: IncomingMessage, res: ServerResponse): void {
186
407
  const url = req.url ?? "";
187
408
 
@@ -208,7 +429,7 @@ export class AftRpcServer {
208
429
  let body = "";
209
430
  req.on("data", (chunk: Buffer) => {
210
431
  body += chunk.toString();
211
- if (body.length > 1_048_576) {
432
+ if (body.length > MAX_BODY_BYTES) {
212
433
  res.writeHead(413);
213
434
  res.end("Request too large");
214
435
  req.destroy();
@@ -227,7 +448,7 @@ export class AftRpcServer {
227
448
  return;
228
449
  }
229
450
 
230
- if (params.token !== this.token) {
451
+ if (!tokensMatch(typeof params.token === "string" ? params.token : "", this.token)) {
231
452
  res.writeHead(403, { "Content-Type": "application/json" });
232
453
  res.end(JSON.stringify({ error: "Forbidden" }));
233
454
  return;
@@ -235,9 +456,7 @@ export class AftRpcServer {
235
456
 
236
457
  const { token: _token, ...handlerParams } = params;
237
458
 
238
- // Successful RPC calls are intentionally NOT logged. The /aft-status
239
- // TUI dialog polls every 1.5s while open, which used to spam the log
240
- // with RPC call/result pairs. Errors are still logged (real signal).
459
+ // Successful RPC calls are not logged to avoid noise; errors are still logged.
241
460
  handler(handlerParams)
242
461
  .then((result) => {
243
462
  res.writeHead(200, { "Content-Type": "application/json" });