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