@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.
- package/dist/agents/magic-context-prompt.d.ts +1 -1
- package/dist/agents/magic-context-prompt.d.ts.map +1 -1
- package/dist/config/schema/magic-context.d.ts +11 -0
- package/dist/config/schema/magic-context.d.ts.map +1 -1
- package/dist/features/magic-context/compartment-chunk-embedding.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/retrospective-raw-provider.d.ts +0 -1
- package/dist/features/magic-context/dreamer/retrospective-raw-provider.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/storage-task-schedule.d.ts +10 -0
- package/dist/features/magic-context/dreamer/storage-task-schedule.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/task-executor.d.ts +0 -3
- package/dist/features/magic-context/dreamer/task-executor.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/task-prompts.d.ts +1 -1
- package/dist/features/magic-context/dreamer/task-prompts.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/task-registry.d.ts +0 -1
- package/dist/features/magic-context/dreamer/task-registry.d.ts.map +1 -1
- package/dist/features/magic-context/memory/embedding-openai.d.ts.map +1 -1
- package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
- package/dist/features/magic-context/recursive-text-splitter.d.ts +36 -0
- package/dist/features/magic-context/recursive-text-splitter.d.ts.map +1 -0
- package/dist/features/magic-context/smart-notes/sandbox-runner.d.ts.map +1 -1
- package/dist/features/magic-context/storage-db.d.ts +2 -21
- package/dist/features/magic-context/storage-db.d.ts.map +1 -1
- package/dist/features/magic-context/storage-schema-helpers.d.ts +30 -0
- package/dist/features/magic-context/storage-schema-helpers.d.ts.map +1 -0
- package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
- package/dist/features/magic-context/types.d.ts +12 -1
- package/dist/features/magic-context/types.d.ts.map +1 -1
- package/dist/hooks/magic-context/apply-operations.d.ts +8 -1
- package/dist/hooks/magic-context/apply-operations.d.ts.map +1 -1
- package/dist/hooks/magic-context/channel2-delivery.d.ts +9 -5
- package/dist/hooks/magic-context/channel2-delivery.d.ts.map +1 -1
- package/dist/hooks/magic-context/edit-marker.d.ts +11 -0
- package/dist/hooks/magic-context/edit-marker.d.ts.map +1 -0
- package/dist/hooks/magic-context/event-handler.d.ts +1 -4
- package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
- package/dist/hooks/magic-context/hook.d.ts +1 -2
- package/dist/hooks/magic-context/hook.d.ts.map +1 -1
- package/dist/hooks/magic-context/read-session-formatting.d.ts.map +1 -1
- package/dist/hooks/magic-context/supersession-reclaim.d.ts +34 -0
- package/dist/hooks/magic-context/supersession-reclaim.d.ts.map +1 -0
- package/dist/hooks/magic-context/system-prompt-hash.d.ts +5 -0
- package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
- package/dist/hooks/magic-context/tag-messages.d.ts +8 -0
- package/dist/hooks/magic-context/tag-messages.d.ts.map +1 -1
- package/dist/hooks/magic-context/tool-drop-target.d.ts +2 -0
- package/dist/hooks/magic-context/tool-drop-target.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +8 -0
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform.d.ts +4 -0
- package/dist/hooks/magic-context/transform.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3587 -5086
- package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
- package/dist/plugin/rpc-handlers.d.ts.map +1 -1
- package/dist/plugin/tool-registry.d.ts.map +1 -1
- package/dist/shared/announcement.d.ts +1 -1
- package/dist/shared/announcement.d.ts.map +1 -1
- package/dist/shared/commit-detection.d.ts +29 -0
- package/dist/shared/commit-detection.d.ts.map +1 -0
- package/dist/shared/data-path.d.ts.map +1 -1
- package/dist/shared/exit-abort-registry.d.ts +25 -0
- package/dist/shared/exit-abort-registry.d.ts.map +1 -0
- package/dist/shared/harness-provider-map.d.ts +30 -0
- package/dist/shared/harness-provider-map.d.ts.map +1 -0
- package/dist/shared/rpc-client.d.ts +8 -0
- package/dist/shared/rpc-client.d.ts.map +1 -1
- package/dist/shared/rpc-notifications.d.ts +28 -10
- package/dist/shared/rpc-notifications.d.ts.map +1 -1
- package/dist/shared/rpc-server.d.ts +22 -3
- package/dist/shared/rpc-server.d.ts.map +1 -1
- package/dist/shared/tag-transcript.d.ts.map +1 -1
- package/dist/shared/transcript.d.ts +15 -0
- package/dist/shared/transcript.d.ts.map +1 -1
- package/dist/tools/ctx-note/tools.d.ts.map +1 -1
- package/dist/tui/badge-contrast.d.ts +37 -22
- package/dist/tui/badge-contrast.d.ts.map +1 -1
- package/dist/tui/data/context-db.d.ts +4 -14
- package/dist/tui/data/context-db.d.ts.map +1 -1
- package/dist/tui/data/notification-socket.d.ts +39 -0
- package/dist/tui/data/notification-socket.d.ts.map +1 -0
- package/package.json +78 -77
- package/src/shared/announcement.ts +2 -3
- package/src/shared/commit-detection.test.ts +63 -0
- package/src/shared/commit-detection.ts +53 -0
- package/src/shared/data-path.test.ts +28 -0
- package/src/shared/data-path.ts +5 -0
- package/src/shared/exit-abort-registry.test.ts +50 -0
- package/src/shared/exit-abort-registry.ts +46 -0
- package/src/shared/harness-provider-map.test.ts +63 -0
- package/src/shared/harness-provider-map.ts +56 -0
- package/src/shared/rpc-client.ts +14 -0
- package/src/shared/rpc-notifications.test.ts +68 -11
- package/src/shared/rpc-notifications.ts +75 -36
- package/src/shared/rpc-server.ts +249 -150
- package/src/shared/tag-transcript.ts +32 -0
- package/src/shared/transcript-opencode.ts +33 -0
- package/src/shared/transcript.ts +17 -0
- package/src/tui/badge-contrast.test.ts +39 -1
- package/src/tui/badge-contrast.ts +63 -25
- package/src/tui/data/context-db.ts +10 -64
- package/src/tui/data/notification-socket.ts +229 -0
- package/src/tui/index.tsx +68 -118
- package/src/tui/slots/sidebar-content.tsx +2 -2
- package/dist/hooks/is-anthropic-provider.d.ts +0 -2
- package/dist/hooks/is-anthropic-provider.d.ts.map +0 -1
- package/dist/shared/live-server-client.d.ts +0 -50
- package/dist/shared/live-server-client.d.ts.map +0 -1
- package/src/shared/live-server-client.ts +0 -152
package/src/shared/rpc-server.ts
CHANGED
|
@@ -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
|
|
41
|
-
// endpoints (recomp/upgrade/dismiss)
|
|
42
|
-
// browser-origin script that merely
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
/**
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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,
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
205
|
-
res.end(JSON.stringify({ error: `Unknown method: ${method}` }));
|
|
206
|
-
return;
|
|
249
|
+
return json({ error: `Unknown method: ${method}` }, 404);
|
|
207
250
|
}
|
|
208
251
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
223
|
-
params = JSON.parse(body);
|
|
224
|
-
}
|
|
259
|
+
params = JSON.parse(bodyText);
|
|
225
260
|
} catch {
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
+
}
|
package/src/shared/transcript.ts
CHANGED
|
@@ -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", () => {
|