@wolfx/opencode-magic-context 0.28.0 → 0.30.3

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 (108) hide show
  1. package/dist/agents/magic-context-prompt.d.ts +1 -1
  2. package/dist/agents/magic-context-prompt.d.ts.map +1 -1
  3. package/dist/config/schema/magic-context.d.ts +11 -0
  4. package/dist/config/schema/magic-context.d.ts.map +1 -1
  5. package/dist/features/magic-context/compartment-chunk-embedding.d.ts.map +1 -1
  6. package/dist/features/magic-context/dreamer/retrospective-raw-provider.d.ts +0 -1
  7. package/dist/features/magic-context/dreamer/retrospective-raw-provider.d.ts.map +1 -1
  8. package/dist/features/magic-context/dreamer/storage-task-schedule.d.ts +10 -0
  9. package/dist/features/magic-context/dreamer/storage-task-schedule.d.ts.map +1 -1
  10. package/dist/features/magic-context/dreamer/task-executor.d.ts +0 -3
  11. package/dist/features/magic-context/dreamer/task-executor.d.ts.map +1 -1
  12. package/dist/features/magic-context/dreamer/task-prompts.d.ts +1 -1
  13. package/dist/features/magic-context/dreamer/task-prompts.d.ts.map +1 -1
  14. package/dist/features/magic-context/dreamer/task-registry.d.ts +0 -1
  15. package/dist/features/magic-context/dreamer/task-registry.d.ts.map +1 -1
  16. package/dist/features/magic-context/memory/embedding-openai.d.ts.map +1 -1
  17. package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
  18. package/dist/features/magic-context/recursive-text-splitter.d.ts +36 -0
  19. package/dist/features/magic-context/recursive-text-splitter.d.ts.map +1 -0
  20. package/dist/features/magic-context/smart-notes/sandbox-runner.d.ts.map +1 -1
  21. package/dist/features/magic-context/storage-db.d.ts +2 -21
  22. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  23. package/dist/features/magic-context/storage-schema-helpers.d.ts +30 -0
  24. package/dist/features/magic-context/storage-schema-helpers.d.ts.map +1 -0
  25. package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
  26. package/dist/features/magic-context/types.d.ts +12 -1
  27. package/dist/features/magic-context/types.d.ts.map +1 -1
  28. package/dist/hooks/magic-context/apply-operations.d.ts +8 -1
  29. package/dist/hooks/magic-context/apply-operations.d.ts.map +1 -1
  30. package/dist/hooks/magic-context/channel2-delivery.d.ts +9 -5
  31. package/dist/hooks/magic-context/channel2-delivery.d.ts.map +1 -1
  32. package/dist/hooks/magic-context/edit-marker.d.ts +11 -0
  33. package/dist/hooks/magic-context/edit-marker.d.ts.map +1 -0
  34. package/dist/hooks/magic-context/event-handler.d.ts +1 -4
  35. package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
  36. package/dist/hooks/magic-context/hook.d.ts +1 -2
  37. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  38. package/dist/hooks/magic-context/read-session-formatting.d.ts.map +1 -1
  39. package/dist/hooks/magic-context/supersession-reclaim.d.ts +34 -0
  40. package/dist/hooks/magic-context/supersession-reclaim.d.ts.map +1 -0
  41. package/dist/hooks/magic-context/system-prompt-hash.d.ts +5 -0
  42. package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
  43. package/dist/hooks/magic-context/tag-messages.d.ts +8 -0
  44. package/dist/hooks/magic-context/tag-messages.d.ts.map +1 -1
  45. package/dist/hooks/magic-context/tool-drop-target.d.ts +2 -0
  46. package/dist/hooks/magic-context/tool-drop-target.d.ts.map +1 -1
  47. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +8 -0
  48. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  49. package/dist/hooks/magic-context/transform.d.ts +4 -0
  50. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +3587 -5086
  53. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  54. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  55. package/dist/plugin/tool-registry.d.ts.map +1 -1
  56. package/dist/shared/announcement.d.ts +1 -1
  57. package/dist/shared/announcement.d.ts.map +1 -1
  58. package/dist/shared/commit-detection.d.ts +29 -0
  59. package/dist/shared/commit-detection.d.ts.map +1 -0
  60. package/dist/shared/data-path.d.ts.map +1 -1
  61. package/dist/shared/exit-abort-registry.d.ts +25 -0
  62. package/dist/shared/exit-abort-registry.d.ts.map +1 -0
  63. package/dist/shared/harness-provider-map.d.ts +30 -0
  64. package/dist/shared/harness-provider-map.d.ts.map +1 -0
  65. package/dist/shared/rpc-client.d.ts +8 -0
  66. package/dist/shared/rpc-client.d.ts.map +1 -1
  67. package/dist/shared/rpc-notifications.d.ts +28 -10
  68. package/dist/shared/rpc-notifications.d.ts.map +1 -1
  69. package/dist/shared/rpc-server.d.ts +22 -3
  70. package/dist/shared/rpc-server.d.ts.map +1 -1
  71. package/dist/shared/tag-transcript.d.ts.map +1 -1
  72. package/dist/shared/transcript.d.ts +15 -0
  73. package/dist/shared/transcript.d.ts.map +1 -1
  74. package/dist/tools/ctx-note/tools.d.ts.map +1 -1
  75. package/dist/tui/badge-contrast.d.ts +37 -22
  76. package/dist/tui/badge-contrast.d.ts.map +1 -1
  77. package/dist/tui/data/context-db.d.ts +4 -14
  78. package/dist/tui/data/context-db.d.ts.map +1 -1
  79. package/dist/tui/data/notification-socket.d.ts +39 -0
  80. package/dist/tui/data/notification-socket.d.ts.map +1 -0
  81. package/package.json +78 -77
  82. package/src/shared/announcement.ts +2 -3
  83. package/src/shared/commit-detection.test.ts +63 -0
  84. package/src/shared/commit-detection.ts +53 -0
  85. package/src/shared/data-path.test.ts +28 -0
  86. package/src/shared/data-path.ts +5 -0
  87. package/src/shared/exit-abort-registry.test.ts +50 -0
  88. package/src/shared/exit-abort-registry.ts +46 -0
  89. package/src/shared/harness-provider-map.test.ts +63 -0
  90. package/src/shared/harness-provider-map.ts +56 -0
  91. package/src/shared/rpc-client.ts +14 -0
  92. package/src/shared/rpc-notifications.test.ts +68 -11
  93. package/src/shared/rpc-notifications.ts +75 -36
  94. package/src/shared/rpc-server.ts +249 -150
  95. package/src/shared/tag-transcript.ts +32 -0
  96. package/src/shared/transcript-opencode.ts +33 -0
  97. package/src/shared/transcript.ts +17 -0
  98. package/src/tui/badge-contrast.test.ts +39 -1
  99. package/src/tui/badge-contrast.ts +63 -25
  100. package/src/tui/data/context-db.ts +10 -64
  101. package/src/tui/data/notification-socket.ts +229 -0
  102. package/src/tui/index.tsx +68 -118
  103. package/src/tui/slots/sidebar-content.tsx +2 -2
  104. package/dist/hooks/is-anthropic-provider.d.ts +0 -2
  105. package/dist/hooks/is-anthropic-provider.d.ts.map +0 -1
  106. package/dist/shared/live-server-client.d.ts +0 -50
  107. package/dist/shared/live-server-client.d.ts.map +0 -1
  108. package/src/shared/live-server-client.ts +0 -152
@@ -9,13 +9,36 @@ import {
9
9
  unlinkSync,
10
10
  writeFileSync,
11
11
  } from "node:fs";
12
- import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
13
12
  import { dirname } from "node:path";
13
+ import type { Server, ServerWebSocket } from "bun";
14
14
  import { log } from "./logger";
15
+ import {
16
+ drainNotifications,
17
+ type NotificationSink,
18
+ registerNotificationSink,
19
+ } from "./rpc-notifications";
15
20
  import { isPidAlive, parseRpcPortFile, rpcPortDir, rpcPortFilePath } from "./rpc-utils";
16
21
 
17
22
  type RpcHandler = (params: Record<string, unknown>) => Promise<Record<string, unknown>>;
18
23
 
24
+ /** Max body for an HTTP /rpc call. Matches the previous node:http guard. */
25
+ const MAX_BODY_BYTES = 1_048_576;
26
+ /** A WS client that doesn't authenticate within this window is closed. */
27
+ const WS_AUTH_TIMEOUT_MS = 5_000;
28
+ /** WS close code for an auth failure (private; client treats every close as
29
+ * expected and reconnects after rediscovery, so the exact code is advisory). */
30
+ const WS_CLOSE_UNAUTHORIZED = 4401;
31
+
32
+ /** Per-socket state carried on `ServerWebSocket.data`. */
33
+ interface WsData {
34
+ authed: boolean;
35
+ sessionId?: string;
36
+ /** Removes this socket's sink from the notification registry. */
37
+ unregister?: () => void;
38
+ /** Fires if the client never sends a valid hello. */
39
+ authTimer?: ReturnType<typeof setTimeout>;
40
+ }
41
+
19
42
  /**
20
43
  * Constant-time bearer-token comparison. `timingSafeEqual` throws on
21
44
  * length-mismatched buffers, so guard on length first (the length itself is not
@@ -29,17 +52,32 @@ function tokensMatch(presented: string, expected: string): boolean {
29
52
  return timingSafeEqual(a, b);
30
53
  }
31
54
 
55
+ /**
56
+ * Plugin-private localhost RPC server for TUI ↔ server-plugin communication.
57
+ *
58
+ * Runs on Bun (the OpenCode server runner is a Bun Worker), so it uses
59
+ * `Bun.serve` to host BOTH:
60
+ * - HTTP request/reply routes (`/health`, `/rpc/<method>`) — the TUI's snapshot
61
+ * and dialog-result calls, which are event-driven, not idle; and
62
+ * - a WebSocket endpoint (`/ws`) — a single persistent connection per TUI over
63
+ * which the server PUSHES notifications (dialog/toast actions). This replaces
64
+ * the old 500ms HTTP poll, whose new-connection-per-tick cost was the source
65
+ * of idle TUI CPU (#200). Pi never imports this module, so `Bun.serve` is safe.
66
+ */
32
67
  export class MagicContextRpcServer {
33
- private server: Server | null = null;
68
+ private server: Server<WsData> | null = null;
34
69
  private port = 0;
35
70
  private handlers = new Map<string, RpcHandler>();
36
71
  private portFilePath: string;
37
72
  private portDir: string;
38
73
  private startedAt = Date.now();
74
+ /** Every authenticated WS socket, so dispose can close them all. */
75
+ private sockets = new Set<ServerWebSocket<WsData>>();
39
76
  // Unguessable per-process bearer token, published in the (user-private) port
40
- // file and required on every non-health RPC call. Defends side-effecting
41
- // endpoints (recomp/upgrade/dismiss) against any local process or
42
- // browser-origin script that merely discovers/guesses the port.
77
+ // file and required on every non-health RPC call AND in the WS hello. Defends
78
+ // side-effecting endpoints (recomp/upgrade/dismiss) and the push channel
79
+ // against any local process or browser-origin script that merely
80
+ // discovers/guesses the port.
43
81
  private readonly token = randomBytes(32).toString("hex");
44
82
 
45
83
  constructor(storageDir: string, directory: string) {
@@ -54,84 +92,110 @@ export class MagicContextRpcServer {
54
92
 
55
93
  /** Start the server on a random port, write port to disk. */
56
94
  async start(): Promise<number> {
57
- return new Promise((resolve, reject) => {
58
- const server = createServer((req, res) => this.dispatch(req, res));
59
-
60
- server.on("error", (err) => {
61
- log(`[rpc] server error: ${err.message}`);
62
- reject(err);
63
- });
64
-
65
- server.listen(0, "127.0.0.1", () => {
66
- const addr = server.address();
67
- if (!addr || typeof addr === "string") {
68
- reject(new Error("Failed to get server address"));
69
- return;
70
- }
71
- this.port = addr.port;
72
- this.server = server;
73
-
74
- // Write a per-process port file atomically. Multi-instance
75
- // OpenCode is supported: TUI discovery scans all live pid files
76
- // and picks the most recent instead of cross-wiring via one
77
- // shared project file.
78
- try {
79
- this.warnIfOtherLiveInstance();
80
- const dir = dirname(this.portFilePath);
81
- // The port file holds the per-process bearer token that
82
- // gates side-effecting RPC endpoints (recomp/upgrade/
83
- // dismiss). Under the default umask 0o022 a plain write
84
- // lands at 0o644 in a 0o755 dir — world-readable, so any
85
- // local UID could read the token and drive those endpoints,
86
- // defeating the auth defense. Restrict both: dir 0o700,
87
- // file 0o600. renameSync preserves the tmp file's mode, so
88
- // the 0o600 on the write covers the final file.
89
- mkdirSync(dir, { recursive: true, mode: 0o700 });
90
- // mkdirSync's mode only applies on CREATION — a dir left by an
91
- // older build (or default 0o755 umask) keeps its loose perms, so
92
- // chmod it defensively so the bearer token isn't world-readable.
93
- try {
94
- chmodSync(dir, 0o700);
95
- } catch {
96
- // best-effort
97
- }
98
- const tmpPath = `${this.portFilePath}.tmp`;
99
- // A stale tmp from a crashed write could exist with loose perms;
100
- // writeFileSync's mode only applies on create, so remove it first.
101
- try {
102
- rmSync(tmpPath, { force: true });
103
- } catch {
104
- // best-effort
105
- }
106
- writeFileSync(
107
- tmpPath,
108
- JSON.stringify({
109
- port: this.port,
110
- pid: process.pid,
111
- started_at: this.startedAt,
112
- token: this.token,
113
- }),
114
- { encoding: "utf-8", mode: 0o600 },
115
- );
116
- renameSync(tmpPath, this.portFilePath);
117
- // renameSync preserves the tmp's mode, but chmod the final path
118
- // defensively in case the token file pre-existed with loose perms.
119
- try {
120
- chmodSync(this.portFilePath, 0o600);
121
- } catch {
122
- // best-effort
123
- }
124
- log(`[rpc] server listening on 127.0.0.1:${this.port}`);
125
- } catch (err) {
126
- log(`[rpc] failed to write port file: ${err}`);
127
- }
128
-
129
- resolve(this.port);
130
- });
131
-
132
- // Don't keep the process alive just for the RPC server
133
- server.unref();
95
+ const self = this;
96
+ const server = Bun.serve<WsData>({
97
+ port: 0,
98
+ hostname: "127.0.0.1",
99
+ fetch(req, srv) {
100
+ return self.handleFetch(req, srv);
101
+ },
102
+ websocket: {
103
+ open(ws) {
104
+ // Close the socket if it doesn't authenticate promptly. A
105
+ // never-authenticated socket holds no sink and is harmless,
106
+ // but we don't want to keep raw connections open forever.
107
+ ws.data.authTimer = setTimeout(() => {
108
+ if (!ws.data.authed) ws.close(WS_CLOSE_UNAUTHORIZED, "auth timeout");
109
+ }, WS_AUTH_TIMEOUT_MS);
110
+ },
111
+ message(ws, raw) {
112
+ self.handleWsMessage(ws, raw);
113
+ },
114
+ close(ws) {
115
+ if (ws.data.authTimer) clearTimeout(ws.data.authTimer);
116
+ ws.data.unregister?.();
117
+ self.sockets.delete(ws);
118
+ },
119
+ },
134
120
  });
121
+
122
+ this.server = server;
123
+ this.port = server.port ?? 0;
124
+
125
+ // Write a per-process port file atomically. Multi-instance OpenCode is
126
+ // supported: TUI discovery scans all live pid files and picks the most
127
+ // recent instead of cross-wiring via one shared project file.
128
+ try {
129
+ this.warnIfOtherLiveInstance();
130
+ const dir = dirname(this.portFilePath);
131
+ // The port file holds the per-process bearer token that gates
132
+ // side-effecting RPC endpoints and the push channel. Under the default
133
+ // umask 0o022 a plain write lands at 0o644 in a 0o755 dir —
134
+ // world-readable, so any local UID could read the token. Restrict both:
135
+ // dir 0o700, file 0o600.
136
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
137
+ try {
138
+ chmodSync(dir, 0o700);
139
+ } catch {
140
+ // best-effort
141
+ }
142
+ const tmpPath = `${this.portFilePath}.tmp`;
143
+ // A stale tmp from a crashed write could exist with loose perms;
144
+ // writeFileSync's mode only applies on create, so remove it first.
145
+ try {
146
+ rmSync(tmpPath, { force: true });
147
+ } catch {
148
+ // best-effort
149
+ }
150
+ // Synchronous write so the renameSync below sees a fully-written file
151
+ // (a 0o600 mode keeps the bearer token out of world-readable reach;
152
+ // renameSync preserves the tmp's mode for the final path).
153
+ writeFileSync(
154
+ tmpPath,
155
+ JSON.stringify({
156
+ port: this.port,
157
+ pid: process.pid,
158
+ started_at: this.startedAt,
159
+ token: this.token,
160
+ }),
161
+ { encoding: "utf-8", mode: 0o600 },
162
+ );
163
+ renameSync(tmpPath, this.portFilePath);
164
+ try {
165
+ chmodSync(this.portFilePath, 0o600);
166
+ } catch {
167
+ // best-effort
168
+ }
169
+ log(`[rpc] server listening on 127.0.0.1:${this.port}`);
170
+ } catch (err) {
171
+ log(`[rpc] failed to write port file: ${err}`);
172
+ }
173
+
174
+ return this.port;
175
+ }
176
+
177
+ /** Stop the server: close every socket, stop accepting, remove port file. */
178
+ stop(): void {
179
+ for (const ws of this.sockets) {
180
+ try {
181
+ if (ws.data.authTimer) clearTimeout(ws.data.authTimer);
182
+ ws.data.unregister?.();
183
+ ws.close();
184
+ } catch {
185
+ // best-effort
186
+ }
187
+ }
188
+ this.sockets.clear();
189
+ if (this.server) {
190
+ // `stop(true)` closes active connections too, not just the listener.
191
+ this.server.stop(true);
192
+ this.server = null;
193
+ }
194
+ try {
195
+ unlinkSync(this.portFilePath);
196
+ } catch {
197
+ // Intentional: port file may already be gone
198
+ }
135
199
  }
136
200
 
137
201
  private warnIfOtherLiveInstance(): void {
@@ -150,94 +214,129 @@ export class MagicContextRpcServer {
150
214
  }
151
215
  }
152
216
 
153
- /** Stop the server and clean up port file. */
154
- stop(): void {
155
- if (this.server) {
156
- this.server.close();
157
- this.server = null;
158
- }
159
- try {
160
- unlinkSync(this.portFilePath);
161
- } catch {
162
- // Intentional: port file may already be gone
163
- }
164
- }
217
+ /** HTTP route handler (Bun fetch). Returns a Response, or undefined when the
218
+ * request was upgraded to a WebSocket. */
219
+ private async handleFetch(req: Request, srv: Server<WsData>): Promise<Response | undefined> {
220
+ const url = new URL(req.url);
165
221
 
166
- private dispatch(req: IncomingMessage, res: ServerResponse): void {
167
- const url = req.url ?? "";
222
+ // WebSocket upgrade the persistent push channel.
223
+ if (url.pathname === "/ws") {
224
+ const ok = srv.upgrade(req, { data: { authed: false } });
225
+ if (ok) return undefined;
226
+ return new Response("upgrade failed", { status: 400 });
227
+ }
168
228
 
169
229
  // No wildcard CORS: the only legitimate client is the in-process TUI
170
- // client, which is not a browser origin. Omitting
171
- // Access-Control-Allow-Origin makes browsers refuse to read responses,
172
- // closing the CSRF-style read path a malicious local page could use.
173
-
174
- if (req.method === "GET" && url === "/health") {
175
- res.writeHead(200, { "Content-Type": "application/json" });
176
- res.end(JSON.stringify({ ok: true, pid: process.pid }));
177
- return;
230
+ // client, not a browser origin.
231
+ if (req.method === "GET" && url.pathname === "/health") {
232
+ return json({ ok: true, pid: process.pid });
178
233
  }
179
234
 
180
- if (req.method !== "POST" || !url.startsWith("/rpc/")) {
181
- res.writeHead(404);
182
- res.end("Not Found");
183
- return;
235
+ if (req.method !== "POST" || !url.pathname.startsWith("/rpc/")) {
236
+ return new Response("Not Found", { status: 404 });
184
237
  }
185
238
 
186
239
  // Require the per-process bearer token on every side-effecting call.
187
- // The legitimate TUI client reads it from the same port file it used to
188
- // discover the port; a process that only guessed the port cannot.
189
- // Constant-time compare so a local attacker can't byte-probe the token
190
- // via response-timing (length-guard first, since timingSafeEqual throws
191
- // on length mismatch).
192
- const auth = req.headers.authorization;
240
+ const auth = req.headers.get("authorization");
193
241
  const presented = typeof auth === "string" ? auth.replace(/^Bearer\s+/i, "") : "";
194
242
  if (!tokensMatch(presented, this.token)) {
195
- res.writeHead(401, { "Content-Type": "application/json" });
196
- res.end(JSON.stringify({ error: "Unauthorized" }));
197
- req.resume();
198
- return;
243
+ return json({ error: "Unauthorized" }, 401);
199
244
  }
200
245
 
201
- const method = url.slice(5); // strip "/rpc/"
246
+ const method = url.pathname.slice(5); // strip "/rpc/"
202
247
  const handler = this.handlers.get(method);
203
248
  if (!handler) {
204
- res.writeHead(404, { "Content-Type": "application/json" });
205
- res.end(JSON.stringify({ error: `Unknown method: ${method}` }));
206
- return;
249
+ return json({ error: `Unknown method: ${method}` }, 404);
207
250
  }
208
251
 
209
- let body = "";
210
- req.on("data", (chunk: Buffer) => {
211
- body += chunk.toString();
212
- if (body.length > 1_048_576) {
213
- res.writeHead(413);
214
- res.end("Request too large");
215
- req.destroy();
216
- }
217
- });
218
-
219
- req.on("end", () => {
220
- let params: Record<string, unknown> = {};
252
+ const bodyText = await req.text();
253
+ if (bodyText.length > MAX_BODY_BYTES) {
254
+ return new Response("Request too large", { status: 413 });
255
+ }
256
+ let params: Record<string, unknown> = {};
257
+ if (bodyText.length > 0) {
221
258
  try {
222
- if (body.length > 0) {
223
- params = JSON.parse(body);
224
- }
259
+ params = JSON.parse(bodyText);
225
260
  } catch {
226
- res.writeHead(400, { "Content-Type": "application/json" });
227
- res.end(JSON.stringify({ error: "Invalid JSON" }));
261
+ return json({ error: "Invalid JSON" }, 400);
262
+ }
263
+ }
264
+
265
+ try {
266
+ const result = await handler(params);
267
+ return json(result);
268
+ } catch (err) {
269
+ log(`[rpc] handler error: ${method} => ${err}`);
270
+ return json({ error: String(err) }, 500);
271
+ }
272
+ }
273
+
274
+ /** WS message handler: hello (auth + sink registration + backlog drain) and
275
+ * ack (cursor advance → queue prune). All other messages are ignored. */
276
+ private handleWsMessage(ws: ServerWebSocket<WsData>, raw: string | Buffer): void {
277
+ let msg: { type?: string; token?: string; sessionId?: string; lastReceivedId?: number };
278
+ try {
279
+ msg = JSON.parse(typeof raw === "string" ? raw : raw.toString("utf8"));
280
+ } catch {
281
+ return;
282
+ }
283
+
284
+ if (msg.type === "hello") {
285
+ if (!tokensMatch(typeof msg.token === "string" ? msg.token : "", this.token)) {
286
+ ws.send(JSON.stringify({ type: "error", error: "unauthorized" }));
287
+ ws.close(WS_CLOSE_UNAUTHORIZED, "bad token");
228
288
  return;
229
289
  }
290
+ if (ws.data.authTimer) {
291
+ clearTimeout(ws.data.authTimer);
292
+ ws.data.authTimer = undefined;
293
+ }
294
+ ws.data.authed = true;
295
+ ws.data.sessionId =
296
+ typeof msg.sessionId === "string" && msg.sessionId.length > 0
297
+ ? msg.sessionId
298
+ : undefined;
230
299
 
231
- handler(params)
232
- .then((result) => {
233
- res.writeHead(200, { "Content-Type": "application/json" });
234
- res.end(JSON.stringify(result));
235
- })
236
- .catch((err) => {
237
- log(`[rpc] handler error: ${method} => ${err}`);
238
- res.writeHead(500, { "Content-Type": "application/json" });
239
- res.end(JSON.stringify({ error: String(err) }));
240
- });
241
- });
300
+ // Register a live sink so future pushes reach this socket immediately.
301
+ const sink: NotificationSink = {
302
+ sessionId: ws.data.sessionId,
303
+ send: (notification) => {
304
+ ws.send(JSON.stringify({ type: "notification", notification }));
305
+ },
306
+ };
307
+ ws.data.unregister = registerNotificationSink(sink);
308
+ this.sockets.add(ws);
309
+
310
+ // Deliver any backlog the client hasn't seen (at-least-once). The
311
+ // client sends its highest-handled id in the hello; reconnects after a
312
+ // dropped socket re-deliver from here.
313
+ const lastReceivedId = Number(msg.lastReceivedId ?? 0);
314
+ const backlog = drainNotifications(
315
+ Number.isFinite(lastReceivedId) ? lastReceivedId : 0,
316
+ ws.data.sessionId,
317
+ );
318
+ for (const notification of backlog) {
319
+ ws.send(JSON.stringify({ type: "notification", notification }));
320
+ }
321
+ ws.send(JSON.stringify({ type: "hello-ack" }));
322
+ return;
323
+ }
324
+
325
+ if (msg.type === "ack") {
326
+ // Advance the cursor → prune acked notifications from the queue so it
327
+ // doesn't grow during a long-lived connection. Cheap, event-driven.
328
+ const lastReceivedId = Number(msg.lastReceivedId ?? 0);
329
+ if (Number.isFinite(lastReceivedId) && lastReceivedId > 0) {
330
+ drainNotifications(lastReceivedId, ws.data.sessionId);
331
+ }
332
+ }
242
333
  }
243
334
  }
335
+
336
+ /** JSON Response helper. */
337
+ function json(body: unknown, status = 200): Response {
338
+ return new Response(JSON.stringify(body), {
339
+ status,
340
+ headers: { "Content-Type": "application/json" },
341
+ });
342
+ }
@@ -54,6 +54,7 @@ import {
54
54
  updateTagTokenCount,
55
55
  } from "../features/magic-context/storage-tags";
56
56
  import { makeToolCompositeKey, type Tagger } from "../features/magic-context/tagger";
57
+ import { applyEditMarkerToInput } from "../hooks/magic-context/edit-marker";
57
58
  import { estimateImageTokensFromDataUrl } from "../hooks/magic-context/image-token-estimate";
58
59
  import { estimateTokens } from "../hooks/magic-context/read-session-formatting";
59
60
  import {
@@ -692,6 +693,28 @@ function buildAggregateTarget(tagId: number, occurrences: ToolOccurrence[]): Tag
692
693
  }
693
694
  return any ? "truncated" : "absent";
694
695
  },
696
+ editMarker(): "truncated" | "absent" {
697
+ // Edit-marker: preserve the tool_use input's filePath + a region
698
+ // hint of the diff, sentinelize the result half. Separate from
699
+ // truncate() so the existing skeleton bytes are never touched.
700
+ // Deterministic + idempotent (re-derived from source each pass; the
701
+ // region-hint clamp self-guards via ...[truncated]).
702
+ const sentinel = `[dropped \u00a7${tagId}\u00a7]`;
703
+ let any = false;
704
+ for (const occ of occurrences) {
705
+ if (occ.kind === "tool_use") {
706
+ const input = occ.part.getToolInput?.();
707
+ if (input) {
708
+ const next = { ...input };
709
+ applyEditMarkerToInput(next);
710
+ if (occ.part.setToolInput?.(next)) any = true;
711
+ }
712
+ } else if (setToolContentOrText(occ.part, sentinel)) {
713
+ any = true;
714
+ }
715
+ }
716
+ return any ? "truncated" : "absent";
717
+ },
695
718
  // Non-mutating reclaim predicate (Pi parity with OpenCode's canDrop).
696
719
  // Pi sentinelizes BOTH halves, so unlike OpenCode there's no
697
720
  // result-part requirement — a target reclaims as long as it still has
@@ -699,6 +722,15 @@ function buildAggregateTarget(tagId: number, occurrences: ToolOccurrence[]): Tag
699
722
  canDrop(): boolean {
700
723
  return occurrences.length > 0;
701
724
  },
725
+ // Non-mutating read of the invocation input (the tool_use occurrence
726
+ // carries the arguments). Used by smart-drops supersession selection.
727
+ readInput(): Record<string, unknown> | null {
728
+ for (const occ of occurrences) {
729
+ const input = occ.part.getToolInput?.();
730
+ if (input) return input;
731
+ }
732
+ return null;
733
+ },
702
734
  message: {
703
735
  info: { id: messageId, role },
704
736
  parts: [],
@@ -137,6 +137,12 @@ function createOpenCodePart(
137
137
  } {
138
138
  return readOpenCodeToolMetadata(rawPart);
139
139
  },
140
+ getToolInput(): Record<string, unknown> | null {
141
+ return readOpenCodeToolInput(rawPart);
142
+ },
143
+ setToolInput(input: Record<string, unknown>): boolean {
144
+ return writeOpenCodeToolInput(rawPart, input);
145
+ },
140
146
  replaceWithSentinel(sentinelText: string): boolean {
141
147
  // Build a synthetic text part that carries the sentinel as
142
148
  // its content. Subsequent passes see this as a normal text
@@ -267,3 +273,30 @@ function readOpenCodeToolMetadata(part: unknown): {
267
273
 
268
274
  return { toolName, inputByteSize, inputTokenCount };
269
275
  }
276
+
277
+ /** Non-mutating read of an OpenCode tool part's input object (or null). */
278
+ function readOpenCodeToolInput(part: unknown): Record<string, unknown> | null {
279
+ if (!isRecord(part)) return null;
280
+ const state = isRecord(part.state) ? part.state : null;
281
+ const input = state?.input ?? part.args ?? part.input;
282
+ return isRecord(input) ? input : null;
283
+ }
284
+
285
+ /** Replace an OpenCode tool part's input object in place. Returns true if a
286
+ * writable input slot was found. */
287
+ function writeOpenCodeToolInput(part: unknown, input: Record<string, unknown>): boolean {
288
+ if (!isRecord(part)) return false;
289
+ if (isRecord(part.state) && isRecord(part.state.input)) {
290
+ part.state.input = input;
291
+ return true;
292
+ }
293
+ if (isRecord(part.args)) {
294
+ part.args = input;
295
+ return true;
296
+ }
297
+ if (isRecord(part.input)) {
298
+ part.input = input;
299
+ return true;
300
+ }
301
+ return false;
302
+ }
@@ -133,6 +133,23 @@ export interface TranscriptPart {
133
133
  inputTokenCount: number;
134
134
  };
135
135
 
136
+ /**
137
+ * Non-mutating read of this tool invocation's input object, or null for
138
+ * non-tool parts / parts without an input. Used by smart-drops supersession
139
+ * selection (read `ctx_note`'s action, an edit's `filePath`) without
140
+ * touching the wire. Returns the live object reference; callers must NOT
141
+ * mutate it.
142
+ */
143
+ getToolInput?(): Record<string, unknown> | null;
144
+
145
+ /**
146
+ * Replace this tool invocation's input object with `input`. Used by the
147
+ * smart-drops edit_marker path to write back a filePath-preserving,
148
+ * region-hint-clamped copy of an edit's arguments. Returns true if the part
149
+ * carried a writable tool input. No-op (false) for non-tool parts.
150
+ */
151
+ setToolInput?(input: Record<string, unknown>): boolean;
152
+
136
153
  /**
137
154
  * Replace this part with a sentinel placeholder. Sentinels look like
138
155
  * `[dropped §N§]` or `[truncated §N§]` and survive cache-busting
@@ -1,5 +1,43 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { readableTextColorOn } from "./badge-contrast";
2
+ import { badgeTextColor, readableTextColorOn } from "./badge-contrast";
3
+
4
+ describe("badgeTextColor (AFT parity with #186 safety net)", () => {
5
+ // A purple/lavender accent on a dark theme: the badge label should be the
6
+ // theme background (the inverse-of-panel look), the same fixed token the AFT
7
+ // sidebar uses, so two sibling badges never disagree on the same accent.
8
+ const accent = { r: 0.6, g: 0.5, b: 0.9, a: 1 };
9
+
10
+ test("opaque distinct background is used verbatim as the label", () => {
11
+ const background = { r: 0.05, g: 0.05, b: 0.07, a: 1 }; // near-black dark theme
12
+ // Returns the SAME background token rather than a computed color.
13
+ expect(badgeTextColor(accent, background)).toBe(background);
14
+ });
15
+
16
+ test("light theme: background is used verbatim too (near-white label inverse)", () => {
17
+ const background = { r: 0.97, g: 0.97, b: 0.95, a: 1 };
18
+ expect(badgeTextColor(accent, background)).toBe(background);
19
+ });
20
+
21
+ test("transparent background (alpha 0) falls back to a visible pick (#186)", () => {
22
+ // background:"none" resolves to RGBA(0,0,0,0); using it as text would
23
+ // render the label invisible, so fall back to a contrast pick on accent.
24
+ const transparent = { r: 0, g: 0, b: 0, a: 0 };
25
+ const result = badgeTextColor(accent, transparent);
26
+ expect(result).not.toBe(transparent);
27
+ expect(result).toBe(readableTextColorOn(accent));
28
+ });
29
+
30
+ test("background ~= accent falls back to a visible pick", () => {
31
+ const sameAsAccent = { r: 0.6, g: 0.5, b: 0.9, a: 1 };
32
+ const result = badgeTextColor(accent, sameAsAccent);
33
+ expect(result).toBe(readableTextColorOn(accent));
34
+ });
35
+
36
+ test("missing alpha is treated as opaque", () => {
37
+ const background = { r: 0.05, g: 0.05, b: 0.07 };
38
+ expect(badgeTextColor(accent, background)).toBe(background);
39
+ });
40
+ });
3
41
 
4
42
  describe("readableTextColorOn", () => {
5
43
  test("dark accent gets white text", () => {