ctxpool 0.1.0 → 0.4.0

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 (58) hide show
  1. package/README.md +95 -105
  2. package/dist/cli.js +328 -184
  3. package/dist/cli.js.map +1 -1
  4. package/dist/clipboard.js +8 -10
  5. package/dist/clipboard.js.map +1 -1
  6. package/dist/format.d.ts +32 -0
  7. package/dist/format.js +110 -0
  8. package/dist/format.js.map +1 -0
  9. package/dist/handoff.d.ts +16 -13
  10. package/dist/handoff.js +441 -706
  11. package/dist/handoff.js.map +1 -1
  12. package/dist/mcp.js +99 -221
  13. package/dist/mcp.js.map +1 -1
  14. package/dist/pack.d.ts +14 -0
  15. package/dist/pack.js +121 -0
  16. package/dist/pack.js.map +1 -0
  17. package/dist/paths.d.ts +15 -0
  18. package/dist/paths.js +35 -5
  19. package/dist/paths.js.map +1 -1
  20. package/dist/redact.d.ts +6 -0
  21. package/dist/redact.js +85 -0
  22. package/dist/redact.js.map +1 -0
  23. package/dist/repo.d.ts +6 -1
  24. package/dist/repo.js +109 -123
  25. package/dist/repo.js.map +1 -1
  26. package/dist/session.d.ts +40 -1
  27. package/dist/session.js +1295 -302
  28. package/dist/session.js.map +1 -1
  29. package/dist/store.d.ts +13 -6
  30. package/dist/store.js +88 -115
  31. package/dist/store.js.map +1 -1
  32. package/dist/text.d.ts +6 -0
  33. package/dist/text.js +25 -0
  34. package/dist/text.js.map +1 -0
  35. package/dist/types.d.ts +44 -7
  36. package/dist/ui.d.ts +10 -1
  37. package/dist/ui.js +329 -383
  38. package/dist/ui.js.map +1 -1
  39. package/dist/util.d.ts +10 -1
  40. package/dist/util.js +40 -10
  41. package/dist/util.js.map +1 -1
  42. package/dist/verify.d.ts +16 -0
  43. package/dist/verify.js +74 -0
  44. package/dist/verify.js.map +1 -0
  45. package/dist/version.d.ts +1 -0
  46. package/dist/version.js +4 -0
  47. package/dist/version.js.map +1 -0
  48. package/dist-web/assets/contextpool-mark-4vSRiUeZ.svg +1 -0
  49. package/dist-web/assets/index-BBjy_S2c.css +2 -0
  50. package/dist-web/assets/index-QwGbBEu2.js +17 -0
  51. package/dist-web/index.html +4 -3
  52. package/package.json +19 -18
  53. package/dist/logs.d.ts +0 -9
  54. package/dist/logs.js +0 -82
  55. package/dist/logs.js.map +0 -1
  56. package/dist-web/assets/contextpool-mark-Bu_00i5t.svg +0 -16
  57. package/dist-web/assets/index-BfHRocKl.js +0 -14
  58. package/dist-web/assets/index-l1QjYqDb.css +0 -2
package/dist/ui.js CHANGED
@@ -1,273 +1,239 @@
1
1
  import { createServer } from "node:http";
2
- import { createReadStream, existsSync, readFileSync, statSync } from "node:fs";
2
+ import { createReadStream, existsSync, readFileSync, realpathSync, statSync } from "node:fs";
3
+ import { networkInterfaces } from "node:os";
3
4
  import { spawnSync } from "node:child_process";
4
5
  import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
5
6
  import { fileURLToPath } from "node:url";
6
- import { buildAgentContext, buildTeamTimelineExport, createContextPack, deriveContextBlocks, renderContextPackOutput } from "./handoff.js";
7
+ import { buildAgentContext, buildTeamTimelineExport, deriveContextBlocks } from "./handoff.js";
8
+ import { createContextPack, renderContextPackOutput } from "./pack.js";
9
+ import { redactSecrets } from "./redact.js";
7
10
  import { openStore } from "./store.js";
11
+ import { CtxError } from "./util.js";
8
12
  const MAX_FILE_VIEW_BYTES = 512 * 1024;
9
- const MAX_JSON_BODY_BYTES = 1024 * 1024;
13
+ const MAX_JSON_BODY_BYTES = 2 * 1024 * 1024;
14
+ const LOOPBACK = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
15
+ class HttpError extends Error {
16
+ status;
17
+ constructor(status, message) {
18
+ super(message);
19
+ this.status = status;
20
+ }
21
+ }
10
22
  export function isValidContextPackBody(body) {
11
23
  return Boolean(parseContextPackBody(body));
12
24
  }
13
25
  export async function startUi(options = {}) {
14
26
  const host = options.host || "127.0.0.1";
15
27
  const requestedPort = options.port ?? 4377;
16
- const ports = options.port ? [requestedPort] : Array.from({ length: 11 }, (_, index) => requestedPort + index);
28
+ const ports = options.fallback === false ? [requestedPort] : Array.from({ length: 11 }, (_, index) => requestedPort + index);
29
+ const allowedHosts = allowedHostnames(host);
17
30
  for (const port of ports) {
18
- const server = createUiServer();
31
+ const server = createServer((req, res) => {
32
+ handle(req, res, allowedHosts).catch((error) => {
33
+ const status = error instanceof HttpError ? error.status : 500;
34
+ if (!res.headersSent)
35
+ sendJson(res, status, { error: error instanceof Error ? error.message : "Unknown error" });
36
+ else
37
+ res.end();
38
+ });
39
+ });
19
40
  const result = await listen(server, host, port);
20
41
  if (result.ok) {
21
- console.log(`ContextPool UI running at http://${host}:${port}`);
22
- return;
42
+ const address = server.address();
43
+ const boundPort = typeof address === "object" && address ? address.port : port;
44
+ const displayHost = host.includes(":") ? `[${host}]` : host;
45
+ return { url: `http://${displayHost}:${boundPort}`, host, port: boundPort, close: () => closeServer(server) };
23
46
  }
24
- server.close();
25
- if (options.port || result.code !== "EADDRINUSE") {
26
- throw new Error(`Unable to start ContextPool UI on ${host}:${port}: ${result.code}`);
47
+ if (result.code !== "EADDRINUSE" || ports.length === 1) {
48
+ throw new CtxError(`Could not start the UI on ${host}:${port} (${result.code}).`, result.code === "EADDRINUSE" ? "Pick another port with --port." : undefined);
27
49
  }
28
50
  }
29
- throw new Error(`Unable to start ContextPool UI. Ports ${ports[0]}-${ports[ports.length - 1]} are already in use.`);
30
- }
31
- function createUiServer() {
32
- return createServer(async (req, res) => {
33
- try {
34
- await route(req, res);
35
- }
36
- catch (error) {
37
- sendJson(res, 500, { error: error instanceof Error ? error.message : "Unknown error" });
38
- }
39
- });
51
+ throw new CtxError(`Ports ${ports[0]}–${ports[ports.length - 1]} are all in use.`, "Pick another port with --port.");
40
52
  }
41
53
  function listen(server, host, port) {
42
- return new Promise((resolve) => {
43
- server.once("error", (error) => {
44
- resolve({ ok: false, code: error.code || error.message });
45
- });
54
+ return new Promise((resolvePromise) => {
55
+ server.once("error", (error) => resolvePromise({ ok: false, code: error.code || error.message }));
46
56
  server.listen(port, host, () => {
47
57
  server.removeAllListeners("error");
48
- resolve({ ok: true });
58
+ resolvePromise({ ok: true });
49
59
  });
50
60
  });
51
61
  }
52
- async function route(req, res) {
53
- const url = new URL(req.url || "/", "http://127.0.0.1");
62
+ function closeServer(server) {
63
+ return new Promise((resolvePromise) => {
64
+ server.closeAllConnections?.();
65
+ server.close(() => resolvePromise());
66
+ });
67
+ }
68
+ // ---------------------------------------------------------------------------
69
+ // Routing
70
+ async function handle(req, res, allowedHosts) {
71
+ setSecurityHeaders(res);
72
+ const url = new URL(req.url || "/", "http://localhost");
54
73
  const method = req.method || "GET";
55
- if (isMutatingMethod(method) && !isLocalRequest(req)) {
56
- return sendJson(res, 403, { error: "ContextPool UI only accepts local mutating requests" });
74
+ // Reject requests addressed to any other hostname. This blocks DNS-rebinding pages from reading local data.
75
+ if (!isAllowedRequest(req, allowedHosts))
76
+ return sendJson(res, 403, { error: "Forbidden host or origin" });
77
+ if (isMutatingMethod(method) && !isJsonRequest(req))
78
+ return sendJson(res, 415, { error: "Use Content-Type: application/json" });
79
+ const path = url.pathname;
80
+ const param = (prefix, suffix = "") => decodeURIComponent(path.slice(prefix.length, suffix ? -suffix.length : undefined));
81
+ if (path === "/api/tasks")
82
+ return withStore((store) => sendJson(res, 200, { tasks: store.listTasks() }));
83
+ if (path === "/api/handoffs") {
84
+ return withStore((store) => sendJson(res, 200, { handoffs: store.listHandoffPackets(url.searchParams.get("task") || undefined) }));
57
85
  }
58
- if (url.pathname === "/api/tasks") {
59
- const store = await openStore();
60
- try {
61
- return sendJson(res, 200, { tasks: store.listTasks() });
62
- }
63
- finally {
64
- store.close();
65
- }
66
- }
67
- if (url.pathname === "/api/handoffs") {
68
- const store = await openStore();
69
- try {
70
- return sendJson(res, 200, { handoffs: store.listHandoffPackets(url.searchParams.get("task") || undefined) });
71
- }
72
- finally {
73
- store.close();
74
- }
75
- }
76
- if (url.pathname === "/api/context-blocks") {
77
- const task = url.searchParams.get("task") || undefined;
78
- const store = await openStore();
79
- try {
80
- const handoffs = store.listHandoffPackets(task);
81
- const blocks = handoffs.flatMap(deriveContextBlocks);
82
- return sendJson(res, 200, { blocks });
83
- }
84
- finally {
85
- store.close();
86
- }
86
+ if (path === "/api/context-blocks") {
87
+ return withStore((store) => sendJson(res, 200, { blocks: store.listHandoffPackets(url.searchParams.get("task") || undefined).flatMap(deriveContextBlocks) }));
87
88
  }
88
- if (url.pathname === "/api/context-packs") {
89
- const store = await openStore();
90
- try {
91
- if (req.method === "GET")
92
- return sendJson(res, 200, { packs: store.listContextPacks() });
93
- if (req.method !== "POST")
94
- return sendJson(res, 405, { error: "Use GET or POST" });
95
- const body = await readJsonBody(req);
96
- const name = typeof body.name === "string" ? body.name : "Untitled context pack";
97
- const blocks = Array.isArray(body.blocks) ? body.blocks : [];
98
- const includeRawTranscript = body.includeRawTranscript === true;
99
- const explicitPack = parseContextPackBody(body);
100
- const pack = explicitPack || createContextPack(name, blocks, { includeRawTranscript });
101
- return sendJson(res, 200, { pack: store.saveContextPack({ ...pack, updatedAt: new Date().toISOString() }) });
102
- }
103
- finally {
104
- store.close();
105
- }
89
+ if (path === "/api/context-packs") {
90
+ if (method === "GET")
91
+ return withStore((store) => sendJson(res, 200, { packs: store.listContextPacks() }));
92
+ if (method !== "POST")
93
+ return sendJson(res, 405, { error: "Use GET or POST" });
94
+ const body = await readJsonBody(req);
95
+ const explicit = parseContextPackBody(body);
96
+ if (!explicit && Array.isArray(body.blocks) && !body.blocks.every(isContextBlock))
97
+ return sendJson(res, 400, { error: "Invalid context blocks" });
98
+ const pack = explicit ||
99
+ createContextPack(typeof body.name === "string" ? body.name : "Untitled pack", Array.isArray(body.blocks) ? body.blocks : [], {
100
+ includeRawTranscript: body.includeRawTranscript === true
101
+ });
102
+ return withStore((store) => sendJson(res, 200, { pack: store.saveContextPack({ ...pack, updatedAt: new Date().toISOString() }) }));
106
103
  }
107
- if (url.pathname.startsWith("/api/context-packs/") && url.pathname.endsWith("/export")) {
108
- const id = decodeURIComponent(url.pathname.replace("/api/context-packs/", "").replace("/export", ""));
109
- if (!id)
110
- return sendJson(res, 400, { error: "Missing context pack id" });
111
- const store = await openStore();
112
- try {
104
+ if (path.startsWith("/api/context-packs/") && path.endsWith("/export")) {
105
+ const id = param("/api/context-packs/", "/export");
106
+ return withStore((store) => {
113
107
  const pack = store.getContextPackById(id);
114
108
  if (!pack)
115
109
  return sendJson(res, 404, { error: "Context pack not found" });
116
110
  const output = renderContextPackOutput(pack);
117
111
  return url.searchParams.get("format") === "markdown" ? sendText(res, output.markdown) : sendJson(res, 200, output);
118
- }
119
- finally {
120
- store.close();
121
- }
112
+ });
122
113
  }
123
- if (url.pathname.startsWith("/api/context-packs/")) {
124
- const id = decodeURIComponent(url.pathname.replace("/api/context-packs/", ""));
125
- if (!id)
126
- return sendJson(res, 400, { error: "Missing context pack id" });
127
- const store = await openStore();
128
- try {
129
- if (req.method === "GET") {
114
+ if (path.startsWith("/api/context-packs/")) {
115
+ const id = param("/api/context-packs/");
116
+ if (method === "GET") {
117
+ return withStore((store) => {
130
118
  const pack = store.getContextPackById(id);
131
119
  return sendJson(res, pack ? 200 : 404, pack ? { pack } : { error: "Context pack not found" });
132
- }
133
- if (req.method === "DELETE") {
134
- return sendJson(res, store.deleteContextPack(id) ? 200 : 404, { ok: true });
135
- }
136
- if (req.method !== "PUT")
137
- return sendJson(res, 405, { error: "Use GET, PUT, or DELETE" });
138
- const body = await readJsonBody(req);
139
- const pack = parseContextPackBody(body);
140
- if (!pack || pack.id !== id)
141
- return sendJson(res, 400, { error: "Invalid context pack body" });
142
- return sendJson(res, 200, { pack: store.saveContextPack({ ...pack, updatedAt: new Date().toISOString() }) });
143
- }
144
- finally {
145
- store.close();
120
+ });
146
121
  }
122
+ if (method === "DELETE")
123
+ return withStore((store) => (store.deleteContextPack(id) ? sendJson(res, 200, { ok: true }) : sendJson(res, 404, { error: "Context pack not found" })));
124
+ if (method !== "PUT")
125
+ return sendJson(res, 405, { error: "Use GET, PUT, or DELETE" });
126
+ const pack = parseContextPackBody(await readJsonBody(req));
127
+ if (!pack || pack.id !== id)
128
+ return sendJson(res, 400, { error: "Invalid context pack body" });
129
+ return withStore((store) => sendJson(res, 200, { pack: store.saveContextPack({ ...pack, updatedAt: new Date().toISOString() }) }));
147
130
  }
148
- if (url.pathname.startsWith("/api/handoffs/") && url.pathname.endsWith("/agent-context")) {
149
- const id = decodeURIComponent(url.pathname.replace("/api/handoffs/", "").replace("/agent-context", ""));
150
- if (!id)
151
- return sendJson(res, 400, { error: "Missing handoff id" });
152
- const store = await openStore();
153
- try {
131
+ if (path.startsWith("/api/handoffs/") && path.endsWith("/agent-context")) {
132
+ const id = param("/api/handoffs/", "/agent-context");
133
+ return withStore((store) => {
154
134
  const handoff = store.getHandoffById(id);
155
135
  if (!handoff)
156
136
  return sendJson(res, 404, { error: "Handoff not found" });
157
137
  const context = buildAgentContext(handoff);
158
138
  return url.searchParams.get("format") === "json" ? sendJson(res, 200, context) : sendText(res, context.markdown);
159
- }
160
- finally {
161
- store.close();
162
- }
139
+ });
163
140
  }
164
- if (url.pathname.startsWith("/api/handoffs/") && url.pathname.endsWith("/team-export")) {
165
- const id = decodeURIComponent(url.pathname.replace("/api/handoffs/", "").replace("/team-export", ""));
166
- if (!id)
167
- return sendJson(res, 400, { error: "Missing handoff id" });
168
- const store = await openStore();
169
- try {
141
+ if (path.startsWith("/api/handoffs/") && path.endsWith("/team-export")) {
142
+ const id = param("/api/handoffs/", "/team-export");
143
+ return withStore((store) => {
170
144
  const handoff = store.getHandoffById(id);
171
145
  if (!handoff)
172
146
  return sendJson(res, 404, { error: "Handoff not found" });
173
- const includeRawTranscript = url.searchParams.get("includeRawTranscript") === "true";
174
- return sendJson(res, 200, buildTeamTimelineExport(handoff, { includeRawTranscript }));
175
- }
176
- finally {
177
- store.close();
178
- }
147
+ return sendJson(res, 200, buildTeamTimelineExport(handoff, { includeRawTranscript: url.searchParams.get("includeRawTranscript") === "true" }));
148
+ });
179
149
  }
180
- if (url.pathname.startsWith("/api/handoffs/")) {
181
- const id = decodeURIComponent(url.pathname.replace("/api/handoffs/", ""));
182
- if (!id)
183
- return sendJson(res, 400, { error: "Missing handoff id" });
184
- const store = await openStore();
185
- try {
150
+ if (path.startsWith("/api/handoffs/")) {
151
+ const id = param("/api/handoffs/");
152
+ return withStore((store) => {
186
153
  const handoff = store.getHandoffById(id);
187
154
  return sendJson(res, handoff ? 200 : 404, handoff ? { handoff } : { error: "Handoff not found" });
188
- }
189
- finally {
190
- store.close();
191
- }
155
+ });
192
156
  }
193
- if (url.pathname === "/api/latest") {
194
- const store = await openStore();
195
- try {
157
+ if (path === "/api/latest") {
158
+ return withStore((store) => {
196
159
  const handoff = store.getLatestHandoff(url.searchParams.get("task") || undefined);
197
160
  return sendJson(res, handoff ? 200 : 404, handoff ? { handoff } : { error: "No handoff found" });
198
- }
199
- finally {
200
- store.close();
201
- }
161
+ });
202
162
  }
203
- if (url.pathname === "/api/files/view") {
204
- if (req.method !== "GET")
163
+ if (path === "/api/files/view") {
164
+ if (method !== "GET")
205
165
  return sendJson(res, 405, { ok: false, error: "Use GET" });
206
166
  const repoPath = url.searchParams.get("repoPath") || "";
207
- const filePath = url.searchParams.get("path") || "";
208
167
  if (!(await isKnownRepoPath(repoPath)))
209
- return sendJson(res, 403, { ok: false, error: "Repository path is not in saved handoffs" });
210
- const result = readRepoFileForView(repoPath, filePath);
168
+ return sendJson(res, 403, { ok: false, error: "Repository is not in any saved handoff" });
169
+ const result = readRepoFileForView(repoPath, url.searchParams.get("path") || "");
211
170
  return sendJson(res, result.ok ? 200 : fileViewStatus(result.error), result);
212
171
  }
213
- if (url.pathname === "/api/files/open") {
214
- if (req.method !== "POST")
172
+ if (path === "/api/files/open") {
173
+ if (method !== "POST")
215
174
  return sendJson(res, 405, { error: "Use POST" });
216
175
  const body = await readJsonBody(req);
217
176
  const repoPath = typeof body.repoPath === "string" ? body.repoPath : "";
218
- const filePath = typeof body.path === "string" ? body.path : "";
219
- const line = typeof body.line === "number" ? body.line : undefined;
220
- const mode = body.mode === "reveal" ? "reveal" : "open";
221
177
  if (!(await isKnownRepoPath(repoPath)))
222
- return sendJson(res, 403, { ok: false, error: "Repository path is not in saved handoffs" });
223
- const safe = resolveSafeRepoPath(repoPath, filePath);
178
+ return sendJson(res, 403, { ok: false, error: "Repository is not in any saved handoff" });
179
+ const safe = resolveSafeRepoPath(repoPath, typeof body.path === "string" ? body.path : "");
224
180
  if (!safe.ok)
225
181
  return sendJson(res, 400, { ok: false, error: safe.error });
226
182
  if (!existsSync(safe.path))
227
183
  return sendJson(res, 404, { ok: false, error: "File does not exist on disk" });
228
- return sendJson(res, 200, openLocalFile(safe.path, line, mode));
184
+ const line = typeof body.line === "number" && Number.isInteger(body.line) && body.line > 0 ? body.line : undefined;
185
+ return sendJson(res, 200, openLocalFile(safe.path, line, body.mode === "reveal" ? "reveal" : "open"));
229
186
  }
230
- if (trySendStaticAsset(url.pathname, res))
187
+ if (path.startsWith("/api/"))
188
+ return sendJson(res, 404, { error: "Not found" });
189
+ if (method !== "GET" && method !== "HEAD")
190
+ return sendJson(res, 405, { error: "Use GET" });
191
+ if (trySendStaticAsset(path, res))
231
192
  return;
232
- if (url.pathname === "/" || isHandoffPage(url.pathname))
233
- return sendWebApp(res);
234
- sendJson(res, 404, { error: "Not found" });
235
- }
236
- function isHandoffPage(pathname) {
237
- const parts = pathname.split("/").filter(Boolean);
238
- return parts.length === 2 && parts[0] === "handoffs" && parts[1].length > 0;
239
- }
240
- function sendJson(res, status, data) {
241
- res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
242
- res.end(JSON.stringify(data, null, 2));
243
- }
244
- function sendText(res, text) {
245
- res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
246
- res.end(text);
193
+ return sendWebApp(res);
247
194
  }
248
- function sendHtml(res, html) {
249
- res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
250
- res.end(html);
195
+ async function withStore(fn) {
196
+ const store = await openStore();
197
+ try {
198
+ fn(store);
199
+ }
200
+ finally {
201
+ store.close();
202
+ }
251
203
  }
252
- function isMutatingMethod(method) {
253
- return method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
204
+ // ---------------------------------------------------------------------------
205
+ // Request guards
206
+ function allowedHostnames(boundHost) {
207
+ const hosts = new Set(LOOPBACK);
208
+ if (boundHost === "0.0.0.0" || boundHost === "::") {
209
+ for (const addresses of Object.values(networkInterfaces()))
210
+ for (const address of addresses || [])
211
+ hosts.add(address.address);
212
+ }
213
+ else {
214
+ hosts.add(boundHost);
215
+ }
216
+ return hosts;
254
217
  }
255
218
  export function isLocalRequest(req) {
256
- const host = normalizeHost(req.headers.host);
257
- const origin = typeof req.headers.origin === "string" ? req.headers.origin : "";
258
- if (!host || !isLocalHostname(host.hostname))
219
+ return isAllowedRequest(req, LOOPBACK);
220
+ }
221
+ function isAllowedRequest(req, allowed) {
222
+ const host = parseHost(req.headers.host);
223
+ if (!host || !allowed.has(host.hostname))
259
224
  return false;
225
+ const origin = typeof req.headers.origin === "string" ? req.headers.origin : "";
260
226
  if (!origin)
261
227
  return true;
262
228
  try {
263
229
  const parsed = new URL(origin);
264
- return isLocalHostname(parsed.hostname) && (!parsed.port || !host.port || parsed.port === host.port);
230
+ return allowed.has(parsed.hostname) && (parsed.port || "80") === (host.port || "80");
265
231
  }
266
232
  catch {
267
233
  return false;
268
234
  }
269
235
  }
270
- function normalizeHost(value) {
236
+ function parseHost(value) {
271
237
  const raw = Array.isArray(value) ? value[0] : value;
272
238
  if (!raw)
273
239
  return undefined;
@@ -279,8 +245,13 @@ function normalizeHost(value) {
279
245
  return undefined;
280
246
  }
281
247
  }
282
- function isLocalHostname(hostname) {
283
- return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1" || hostname === "[::1]";
248
+ function isMutatingMethod(method) {
249
+ return method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
250
+ }
251
+ function isJsonRequest(req) {
252
+ if (req.method === "DELETE")
253
+ return true;
254
+ return /^application\/json\b/i.test(req.headers["content-type"] || "");
284
255
  }
285
256
  async function isKnownRepoPath(repoPath) {
286
257
  if (!repoPath)
@@ -288,95 +259,12 @@ async function isKnownRepoPath(repoPath) {
288
259
  const requested = resolve(repoPath);
289
260
  const store = await openStore();
290
261
  try {
291
- return store.listHandoffPackets().some((handoff) => resolve(handoff.repo.repoPath) === requested);
262
+ return store.listRepoPaths().some((path) => resolve(path) === requested);
292
263
  }
293
264
  finally {
294
265
  store.close();
295
266
  }
296
267
  }
297
- function sendWebApp(res) {
298
- const indexPath = join(webDistDir(), "index.html");
299
- if (existsSync(indexPath))
300
- return sendHtml(res, readFileSync(indexPath, "utf8"));
301
- return sendHtml(res, `<!doctype html><html><head><title>ContextPool</title><style>body{font-family:ui-sans-serif,system-ui;margin:40px;color:#09090b}code{background:#f4f4f5;padding:2px 4px;border-radius:4px}</style></head><body><h1>ContextPool UI assets are not built</h1><p>Run <code>npm run build:web</code> or <code>npm run build</code>, then restart <code>ctxpool ui</code>.</p></body></html>`);
302
- }
303
- function trySendStaticAsset(pathname, res) {
304
- const extension = extname(pathname);
305
- if (!extension)
306
- return false;
307
- const root = webDistDir();
308
- const requested = resolve(root, `.${decodeURIComponent(pathname)}`);
309
- const rel = relative(root, requested);
310
- if (rel.startsWith("..") || isAbsolute(rel) || !existsSync(requested))
311
- return false;
312
- const stats = statSync(requested);
313
- if (!stats.isFile())
314
- return false;
315
- res.writeHead(200, { "content-type": contentType(extension) });
316
- createReadStream(requested).pipe(res);
317
- return true;
318
- }
319
- function webDistDir() {
320
- const moduleDir = dirname(fileURLToPath(import.meta.url));
321
- return resolve(moduleDir, "..", "dist-web");
322
- }
323
- function contentType(extension) {
324
- switch (extension) {
325
- case ".html":
326
- return "text/html; charset=utf-8";
327
- case ".js":
328
- return "text/javascript; charset=utf-8";
329
- case ".css":
330
- return "text/css; charset=utf-8";
331
- case ".svg":
332
- return "image/svg+xml";
333
- case ".png":
334
- return "image/png";
335
- case ".jpg":
336
- case ".jpeg":
337
- return "image/jpeg";
338
- case ".webp":
339
- return "image/webp";
340
- default:
341
- return "application/octet-stream";
342
- }
343
- }
344
- export function readRepoFileForView(repoPath, filePath) {
345
- const safe = resolveSafeRepoPath(repoPath, filePath);
346
- if (!safe.ok)
347
- return { ok: false, error: safe.error };
348
- if (!existsSync(safe.path))
349
- return { ok: false, path: filePath, error: "File does not exist on disk" };
350
- const stats = statSync(safe.path);
351
- if (!stats.isFile())
352
- return { ok: false, path: filePath, error: "Path is not a file" };
353
- if (stats.size > MAX_FILE_VIEW_BYTES) {
354
- return { ok: false, path: filePath, size: stats.size, error: `File is larger than ${MAX_FILE_VIEW_BYTES} bytes` };
355
- }
356
- const buffer = readFileSync(safe.path);
357
- if (isBinaryFile(filePath, buffer))
358
- return { ok: false, path: filePath, size: stats.size, error: "Binary files cannot be previewed" };
359
- const redacted = redactSensitivePreview(filePath, buffer.toString("utf8"));
360
- return {
361
- ok: true,
362
- path: filePath,
363
- absolutePath: safe.path,
364
- size: stats.size,
365
- language: languageForPath(filePath),
366
- content: redacted.content,
367
- truncated: false,
368
- redacted: redacted.redacted
369
- };
370
- }
371
- function fileViewStatus(error) {
372
- if (/outside|Missing|Repository/.test(error))
373
- return 400;
374
- if (/does not exist/.test(error))
375
- return 404;
376
- if (/larger|Binary|not a file/.test(error))
377
- return 415;
378
- return 400;
379
- }
380
268
  async function readJsonBody(req) {
381
269
  const chunks = [];
382
270
  let size = 0;
@@ -384,173 +272,231 @@ async function readJsonBody(req) {
384
272
  const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
385
273
  size += buffer.length;
386
274
  if (size > MAX_JSON_BODY_BYTES)
387
- throw new Error(`JSON body exceeds ${MAX_JSON_BODY_BYTES} bytes`);
275
+ throw new HttpError(413, `Body exceeds ${MAX_JSON_BODY_BYTES} bytes`);
388
276
  chunks.push(buffer);
389
277
  }
390
- if (chunks.length === 0)
278
+ if (!chunks.length)
391
279
  return {};
392
280
  try {
393
- return JSON.parse(Buffer.concat(chunks).toString("utf8"));
281
+ const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
282
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
283
+ throw new Error();
284
+ return parsed;
394
285
  }
395
286
  catch {
396
- return {};
287
+ throw new HttpError(400, "Body must be a JSON object");
397
288
  }
398
289
  }
399
290
  function parseContextPackBody(body) {
400
291
  if (typeof body.id !== "string" || typeof body.name !== "string" || !Array.isArray(body.items))
401
292
  return undefined;
402
- const now = new Date().toISOString();
403
- const items = body.items.filter(isContextPackItem);
404
- if (items.length !== body.items.length)
293
+ if (!body.items.every(isContextPackItem))
405
294
  return undefined;
295
+ const now = new Date().toISOString();
406
296
  return {
407
297
  id: body.id,
408
298
  name: body.name,
409
299
  createdAt: typeof body.createdAt === "string" ? body.createdAt : now,
410
300
  updatedAt: typeof body.updatedAt === "string" ? body.updatedAt : now,
411
301
  includeRawTranscript: body.includeRawTranscript === true,
412
- items
302
+ items: body.items
413
303
  };
414
304
  }
415
305
  function isContextPackItem(value) {
416
306
  if (!value || typeof value !== "object")
417
307
  return false;
418
308
  const item = value;
419
- return (typeof item.id === "string" &&
420
- typeof item.blockId === "string" &&
421
- typeof item.order === "number" &&
422
- typeof item.enabled === "boolean" &&
423
- isContextBlock(item.block));
309
+ return typeof item.id === "string" && typeof item.blockId === "string" && typeof item.order === "number" && typeof item.enabled === "boolean" && isContextBlock(item.block);
424
310
  }
311
+ const CATEGORIES = new Set(["intent", "instruction", "decision", "constraint", "verification", "risk", "next-action", "changed-file", "transcript"]);
312
+ const PROVENANCE = new Set(["user-authored", "transcript-derived", "diff-derived", "command-derived", "ai-inferred"]);
313
+ const CONFIDENCE = new Set(["high", "medium", "low"]);
425
314
  function isContextBlock(value) {
426
315
  if (!value || typeof value !== "object")
427
316
  return false;
428
317
  const block = value;
429
- const provenance = block.provenance;
318
+ const provenance = (block.provenance || {});
430
319
  return (typeof block.id === "string" &&
431
320
  typeof block.sourceHandoffId === "string" &&
432
321
  typeof block.sourceTask === "string" &&
433
322
  typeof block.sourceCreatedAt === "string" &&
434
- isContextBlockCategory(block.category) &&
323
+ CATEGORIES.has(block.category) &&
435
324
  typeof block.title === "string" &&
436
325
  typeof block.text === "string" &&
437
326
  typeof block.estimatedChars === "number" &&
438
- Boolean(provenance) &&
439
- isProvenanceKind(provenance?.kind) &&
440
- isConfidence(provenance?.confidence) &&
441
- typeof provenance?.source === "string");
442
- }
443
- function isContextBlockCategory(value) {
444
- return (value === "intent" ||
445
- value === "instruction" ||
446
- value === "decision" ||
447
- value === "constraint" ||
448
- value === "verification" ||
449
- value === "risk" ||
450
- value === "next-action" ||
451
- value === "changed-file" ||
452
- value === "transcript");
453
- }
454
- function isProvenanceKind(value) {
455
- return value === "user-authored" || value === "transcript-derived" || value === "diff-derived" || value === "command-derived" || value === "ai-inferred";
456
- }
457
- function isConfidence(value) {
458
- return value === "high" || value === "medium" || value === "low";
327
+ PROVENANCE.has(provenance.kind) &&
328
+ CONFIDENCE.has(provenance.confidence) &&
329
+ typeof provenance.source === "string");
330
+ }
331
+ // ---------------------------------------------------------------------------
332
+ // Responses
333
+ function setSecurityHeaders(res) {
334
+ res.setHeader("X-Content-Type-Options", "nosniff");
335
+ res.setHeader("Referrer-Policy", "no-referrer");
336
+ res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
337
+ res.setHeader("Cross-Origin-Resource-Policy", "same-origin");
338
+ }
339
+ function sendJson(res, status, data) {
340
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
341
+ res.end(JSON.stringify(data));
342
+ }
343
+ function sendText(res, text) {
344
+ res.writeHead(200, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" });
345
+ res.end(text);
346
+ }
347
+ function sendWebApp(res) {
348
+ const indexPath = join(webDistDir(), "index.html");
349
+ const html = existsSync(indexPath)
350
+ ? readFileSync(indexPath, "utf8")
351
+ : `<!doctype html><html><head><meta charset="utf-8"><title>ContextPool</title><style>body{font:15px/1.6 ui-sans-serif,system-ui;margin:15vh auto;max-width:34rem;padding:0 24px;color:#37352f}code{background:#f1f1ef;padding:2px 6px;border-radius:4px}</style></head><body><h1 style="font-weight:600;font-size:20px">UI assets are not built</h1><p>Run <code>npm run build</code>, then restart <code>ctxpool ui</code>.</p></body></html>`;
352
+ res.writeHead(200, {
353
+ "content-type": "text/html; charset=utf-8",
354
+ "cache-control": "no-store",
355
+ "content-security-policy": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"
356
+ });
357
+ res.end(html);
358
+ }
359
+ function trySendStaticAsset(pathname, res) {
360
+ const extension = extname(pathname);
361
+ if (!extension)
362
+ return false;
363
+ const root = webDistDir();
364
+ const requested = resolve(root, `.${decodeURIComponent(pathname)}`);
365
+ const rel = relative(root, requested);
366
+ if (rel.startsWith("..") || isAbsolute(rel) || !existsSync(requested) || !statSync(requested).isFile())
367
+ return false;
368
+ res.writeHead(200, {
369
+ "content-type": contentType(extension),
370
+ "cache-control": rel.startsWith("assets") ? "public, max-age=31536000, immutable" : "no-cache"
371
+ });
372
+ createReadStream(requested).pipe(res);
373
+ return true;
459
374
  }
375
+ function webDistDir() {
376
+ return resolve(dirname(fileURLToPath(import.meta.url)), "..", "dist-web");
377
+ }
378
+ function contentType(extension) {
379
+ const types = {
380
+ ".html": "text/html; charset=utf-8",
381
+ ".js": "text/javascript; charset=utf-8",
382
+ ".css": "text/css; charset=utf-8",
383
+ ".svg": "image/svg+xml",
384
+ ".png": "image/png",
385
+ ".jpg": "image/jpeg",
386
+ ".jpeg": "image/jpeg",
387
+ ".webp": "image/webp",
388
+ ".woff2": "font/woff2",
389
+ ".ico": "image/x-icon"
390
+ };
391
+ return types[extension] || "application/octet-stream";
392
+ }
393
+ // ---------------------------------------------------------------------------
394
+ // File preview
395
+ export function readRepoFileForView(repoPath, filePath) {
396
+ const safe = resolveSafeRepoPath(repoPath, filePath);
397
+ if (!safe.ok)
398
+ return { ok: false, error: safe.error };
399
+ if (!existsSync(safe.path))
400
+ return { ok: false, path: filePath, error: "File does not exist on disk" };
401
+ const stats = statSync(safe.path);
402
+ if (!stats.isFile())
403
+ return { ok: false, path: filePath, error: "Path is not a file" };
404
+ if (stats.size > MAX_FILE_VIEW_BYTES)
405
+ return { ok: false, path: filePath, size: stats.size, error: `File is larger than ${MAX_FILE_VIEW_BYTES} bytes` };
406
+ const buffer = readFileSync(safe.path);
407
+ if (isBinaryFile(filePath, buffer))
408
+ return { ok: false, path: filePath, size: stats.size, error: "Binary files cannot be previewed" };
409
+ const preview = redactPreview(filePath, buffer.toString("utf8"));
410
+ return { ok: true, path: filePath, absolutePath: safe.path, size: stats.size, language: languageForPath(filePath), content: preview.content, truncated: false, redacted: preview.redacted };
411
+ }
412
+ /** Resolve `filePath` inside `repoPath`, following symlinks so a link cannot point outside the repository. */
460
413
  export function resolveSafeRepoPath(repoPath, filePath) {
461
414
  if (!repoPath || !filePath)
462
415
  return { ok: false, error: "Missing repoPath or path" };
463
416
  const repo = resolve(repoPath);
464
417
  if (!existsSync(repo))
465
418
  return { ok: false, error: "Repository path does not exist" };
466
- if (isAbsolute(filePath)) {
467
- const absoluteRelative = relative(repo, resolve(filePath));
468
- if (absoluteRelative.startsWith("..") || isAbsolute(absoluteRelative))
469
- return { ok: false, error: "File is outside the repository" };
470
- return { ok: true, path: resolve(filePath) };
471
- }
472
- const fullPath = resolve(repo, filePath);
473
- const rel = relative(repo, fullPath);
474
- if (rel.startsWith("..") || isAbsolute(rel))
419
+ const target = isAbsolute(filePath) ? resolve(filePath) : resolve(repo, filePath);
420
+ if (!isInside(target, repo))
421
+ return { ok: false, error: "File is outside the repository" };
422
+ if (existsSync(target) && !isInside(realpathSync(target), realpathSync(repo)))
475
423
  return { ok: false, error: "File is outside the repository" };
476
- return { ok: true, path: fullPath };
424
+ return { ok: true, path: target };
425
+ }
426
+ function isInside(path, root) {
427
+ const rel = relative(root, path);
428
+ return !rel.startsWith("..") && !isAbsolute(rel);
429
+ }
430
+ function fileViewStatus(error) {
431
+ if (/does not exist/.test(error))
432
+ return 404;
433
+ if (/larger|Binary|not a file/.test(error))
434
+ return 415;
435
+ return 400;
477
436
  }
478
437
  function isBinaryFile(path, buffer) {
479
438
  if (/\.(png|jpe?g|gif|webp|ico|pdf|zip|gz|tar|tgz|woff2?|ttf|otf|mp4|mov|mp3|wasm|sqlite|db)$/i.test(path))
480
439
  return true;
481
- const sample = buffer.subarray(0, Math.min(buffer.length, 8192));
482
- return sample.includes(0);
440
+ return buffer.subarray(0, Math.min(buffer.length, 8192)).includes(0);
483
441
  }
484
442
  function languageForPath(path) {
485
- switch (extname(path).toLowerCase()) {
486
- case ".ts":
487
- return "typescript";
488
- case ".tsx":
489
- return "tsx";
490
- case ".js":
491
- case ".mjs":
492
- case ".cjs":
493
- return "javascript";
494
- case ".jsx":
495
- return "jsx";
496
- case ".json":
497
- return "json";
498
- case ".css":
499
- return "css";
500
- case ".html":
501
- return "html";
502
- case ".md":
503
- case ".mdx":
504
- return "markdown";
505
- case ".sql":
506
- return "sql";
507
- case ".py":
508
- return "python";
509
- case ".go":
510
- return "go";
511
- case ".rs":
512
- return "rust";
513
- case ".yml":
514
- case ".yaml":
515
- return "yaml";
516
- case ".toml":
517
- return "toml";
518
- case ".sh":
519
- return "shell";
520
- default:
521
- return "text";
522
- }
443
+ const languages = {
444
+ ".ts": "typescript",
445
+ ".tsx": "tsx",
446
+ ".js": "javascript",
447
+ ".mjs": "javascript",
448
+ ".cjs": "javascript",
449
+ ".jsx": "jsx",
450
+ ".json": "json",
451
+ ".css": "css",
452
+ ".html": "html",
453
+ ".md": "markdown",
454
+ ".mdx": "markdown",
455
+ ".sql": "sql",
456
+ ".py": "python",
457
+ ".go": "go",
458
+ ".rs": "rust",
459
+ ".yml": "yaml",
460
+ ".yaml": "yaml",
461
+ ".toml": "toml",
462
+ ".sh": "shell"
463
+ };
464
+ return languages[extname(path).toLowerCase()] || "text";
523
465
  }
524
- function redactSensitivePreview(path, content) {
525
- if (!/(^|\/)\.env(?:\.|$)|\.env\./i.test(path))
526
- return { content, redacted: false };
527
- let redacted = false;
528
- const next = content.replace(/^(\s*(?:[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|DATABASE_URL|URL|DSN)[A-Z0-9_]*)\s*=\s*)(.+)$/gim, (_match, prefix, value) => {
529
- if (!value || value.trim() === "" || value.trim().startsWith("#"))
530
- return `${prefix}${value}`;
531
- redacted = true;
532
- return `${prefix}[redacted]`;
533
- });
534
- return { content: next, redacted };
466
+ function redactPreview(path, content) {
467
+ let next = redactSecrets(content);
468
+ if (/(^|\/)\.env(?:\.|$)/i.test(path)) {
469
+ next = next.replace(/^(\s*(?:export\s+)?[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|DATABASE_URL|URL|DSN)[A-Z0-9_]*\s*=\s*)(.+)$/gim, (match, prefix, value) => value.trim() && !value.trim().startsWith("#") ? `${prefix}[redacted]` : match);
470
+ }
471
+ return { content: next, redacted: next !== content };
535
472
  }
473
+ // Opening these with the OS handler would execute them; reveal them instead.
474
+ const EXECUTABLE = /\.(app|command|tool|terminal|sh|bash|zsh|scpt|applescript|workflow|pkg|dmg|exe|bat|cmd|ps1|msi|desktop|jar)$/i;
536
475
  function openLocalFile(path, line, mode) {
537
- const editor = process.env.CTXPOOL_EDITOR;
538
476
  const editorTarget = line ? `${path}:${line}` : path;
539
- const candidates = mode === "reveal"
540
- ? process.platform === "darwin"
541
- ? [{ cmd: "open", args: ["-R", path], method: "open -R" }]
542
- : [{ cmd: "xdg-open", args: [dirname(path)], method: "xdg-open parent" }]
543
- : [
544
- ...(editor ? [{ cmd: editor, args: [editorTarget], method: "CTXPOOL_EDITOR" }] : []),
545
- { cmd: "code", args: ["-g", editorTarget], method: "code -g" },
546
- { cmd: "cursor", args: ["-g", editorTarget], method: "cursor -g" },
547
- ...(process.platform === "darwin" ? [{ cmd: "open", args: [path], method: "open" }] : [{ cmd: "xdg-open", args: [path], method: "xdg-open" }])
548
- ];
549
- for (const candidate of candidates) {
550
- const result = spawnSync(candidate.cmd, candidate.args, { stdio: "ignore" });
477
+ const reveal = process.platform === "darwin"
478
+ ? [{ cmd: "open", args: ["-R", path], method: "open -R" }]
479
+ : process.platform === "win32"
480
+ ? [{ cmd: "explorer", args: [`/select,${path}`], method: "explorer" }]
481
+ : [{ cmd: "xdg-open", args: [dirname(path)], method: "xdg-open" }];
482
+ const editors = [
483
+ ...(process.env.CTXPOOL_EDITOR ? [{ cmd: process.env.CTXPOOL_EDITOR, args: [editorTarget], method: "CTXPOOL_EDITOR" }] : []),
484
+ { cmd: "cursor", args: ["-g", editorTarget], method: "cursor" },
485
+ { cmd: "code", args: ["-g", editorTarget], method: "code" },
486
+ { cmd: "zed", args: [editorTarget], method: "zed" }
487
+ ];
488
+ const systemOpen = EXECUTABLE.test(path)
489
+ ? reveal
490
+ : process.platform === "darwin"
491
+ ? [{ cmd: "open", args: ["-t", path], method: "open -t" }]
492
+ : process.platform === "win32"
493
+ ? []
494
+ : [{ cmd: "xdg-open", args: [path], method: "xdg-open" }];
495
+ for (const candidate of mode === "reveal" ? reveal : [...editors, ...systemOpen]) {
496
+ const result = spawnSync(candidate.cmd, candidate.args, { stdio: "ignore", timeout: 5_000 });
551
497
  if (!result.error && (result.status === 0 || result.status === null))
552
498
  return { ok: true, method: candidate.method };
553
499
  }
554
- return { ok: false, error: "No local opener succeeded" };
500
+ return { ok: false, error: "No editor or file opener succeeded" };
555
501
  }
556
502
  //# sourceMappingURL=ui.js.map