@prismshadow/penguin-server 0.1.4 → 0.1.5

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/index.js CHANGED
@@ -3,10 +3,11 @@ import { config as loadDotenv } from "dotenv";
3
3
  import { serve } from "@hono/node-server";
4
4
 
5
5
  // src/app.ts
6
- import fs16 from "fs";
6
+ import fs17 from "fs";
7
7
  import fsp from "fs/promises";
8
- import path15 from "path";
8
+ import path16 from "path";
9
9
  import { Hono as Hono21 } from "hono";
10
+ import { bodyLimit } from "hono/body-limit";
10
11
 
11
12
  // src/db/database.ts
12
13
  import { mkdirSync } from "fs";
@@ -299,13 +300,13 @@ var ErrorsRepo = class {
299
300
  };
300
301
  }
301
302
  /** The most recent `limit` entries (reverse chronological order). */
302
- recent(projectId, f = {}, limit = 20) {
303
+ recent(projectId, f = {}, limit = 20, offset = 0) {
303
304
  const { where, params } = this.conds(projectId, f);
304
305
  const rows = this.db.prepare(
305
306
  `SELECT ts, source, code, kind, message
306
307
  FROM error_records WHERE ${where}
307
- ORDER BY id DESC LIMIT :limit`
308
- ).all({ ...params, limit });
308
+ ORDER BY id DESC LIMIT :limit OFFSET :offset`
309
+ ).all({ ...params, limit, offset });
309
310
  return rows.map((r) => ({
310
311
  ts: r.ts,
311
312
  source: r.source,
@@ -1563,6 +1564,14 @@ function projectsRoutes(deps2) {
1563
1564
  const project = await deps2.projectService.createProject(c.var.user, projectId, name);
1564
1565
  return c.json({ project }, 201);
1565
1566
  });
1567
+ app2.patch("/:projectId", async (c) => {
1568
+ const projectId = requireValidId(c, "projectId");
1569
+ const body = await readJson(c);
1570
+ const name = requireString(body, "name", { maxLen: 100, label: "name" }).trim();
1571
+ if (name === "") throw badRequest("name must be at least 1 characters.");
1572
+ const project = await deps2.projectService.renameProject(c.var.user.userId, projectId, name);
1573
+ return c.json({ project });
1574
+ });
1566
1575
  app2.delete("/:projectId", async (c) => {
1567
1576
  await deps2.projectService.deleteProject(c.var.user.userId, requireValidId(c, "projectId"));
1568
1577
  return c.body(null, 204);
@@ -2190,6 +2199,7 @@ async function readItem(deps2, projectId, agentId, name) {
2190
2199
 
2191
2200
  // src/http/routes/benchmarks.ts
2192
2201
  import { Hono as Hono10 } from "hono";
2202
+ var TEXT_PREVIEW_BYTES = 256 * 1024;
2193
2203
  function benchmarksRoutes(deps2) {
2194
2204
  const app2 = new Hono10();
2195
2205
  app2.get("/", async (c) => {
@@ -2199,6 +2209,58 @@ function benchmarksRoutes(deps2) {
2199
2209
  await deps2.agentConfigService.requireExists(projectId, agentId);
2200
2210
  return c.json(await deps2.benchmarks.list(projectId, agentId));
2201
2211
  });
2212
+ app2.get("/:benchmarkId/cases", async (c) => {
2213
+ const projectId = requireValidId(c, "projectId");
2214
+ const agentId = requireValidId(c, "agentId");
2215
+ const benchmarkId = requireValidId(c, "benchmarkId");
2216
+ deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
2217
+ await deps2.agentConfigService.requireExists(projectId, agentId);
2218
+ return c.json(await deps2.benchmarks.listCases(projectId, agentId, benchmarkId));
2219
+ });
2220
+ app2.get("/:benchmarkId/cases/:caseId/files", async (c) => {
2221
+ const projectId = requireValidId(c, "projectId");
2222
+ const agentId = requireValidId(c, "agentId");
2223
+ const benchmarkId = requireValidId(c, "benchmarkId");
2224
+ const caseId = requireValidId(c, "caseId");
2225
+ deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
2226
+ await deps2.agentConfigService.requireExists(projectId, agentId);
2227
+ return c.json(
2228
+ await deps2.benchmarks.listCaseFiles(
2229
+ projectId,
2230
+ agentId,
2231
+ benchmarkId,
2232
+ caseId,
2233
+ c.req.query("path") ?? ""
2234
+ )
2235
+ );
2236
+ });
2237
+ app2.get("/:benchmarkId/cases/:caseId/files/content", async (c) => {
2238
+ const projectId = requireValidId(c, "projectId");
2239
+ const agentId = requireValidId(c, "agentId");
2240
+ const benchmarkId = requireValidId(c, "benchmarkId");
2241
+ const caseId = requireValidId(c, "caseId");
2242
+ deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
2243
+ await deps2.agentConfigService.requireExists(projectId, agentId);
2244
+ const download = c.req.query("download") === "1";
2245
+ const boundedPreview = !download && c.req.query("preview") === "1";
2246
+ const { data, fileName, contentType, scriptable, truncated } = await deps2.benchmarks.readCaseFile(
2247
+ projectId,
2248
+ agentId,
2249
+ benchmarkId,
2250
+ caseId,
2251
+ c.req.query("path") ?? "",
2252
+ boundedPreview ? { maxBytes: TEXT_PREVIEW_BYTES } : void 0
2253
+ );
2254
+ return new Response(new Uint8Array(data), {
2255
+ status: 200,
2256
+ headers: {
2257
+ "Content-Type": !download && scriptable ? "text/plain; charset=utf-8" : contentType,
2258
+ "Content-Disposition": `${download ? "attachment" : "inline"}; filename*=UTF-8''${encodeURIComponent(fileName)}`,
2259
+ "X-Content-Type-Options": "nosniff",
2260
+ ...truncated ? { "X-Content-Truncated": "1" } : {}
2261
+ }
2262
+ });
2263
+ });
2202
2264
  return app2;
2203
2265
  }
2204
2266
 
@@ -2611,12 +2673,32 @@ function usageRoutes(deps2) {
2611
2673
  })
2612
2674
  );
2613
2675
  });
2676
+ app2.get("/errors", (c) => {
2677
+ const projectId = requireValidId(c, "projectId");
2678
+ deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
2679
+ const { offset, limit } = paginationQuery(c);
2680
+ const from = optionalDateParam(c.req.query("from"), "from");
2681
+ const to = optionalDateParam(c.req.query("to"), "to");
2682
+ const agentId = c.req.query("agentId");
2683
+ return c.json(
2684
+ deps2.usageService.queryErrors(projectId, {
2685
+ offset,
2686
+ limit,
2687
+ // Same admin-only rule as the dashboard: a regular member seeing another tenant's
2688
+ // unattributed errors would be a cross-tenant leak.
2689
+ includeGlobalErrors: c.var.user.isAdmin,
2690
+ ...from !== void 0 ? { from } : {},
2691
+ ...to !== void 0 ? { to } : {},
2692
+ ...agentId !== void 0 && agentId !== "" ? { agentId } : {}
2693
+ })
2694
+ );
2695
+ });
2614
2696
  return app2;
2615
2697
  }
2616
2698
 
2617
2699
  // src/http/routes/sessions.ts
2618
- import fs7 from "fs/promises";
2619
- import path6 from "path";
2700
+ import fs8 from "fs/promises";
2701
+ import path7 from "path";
2620
2702
  import { Hono as Hono18 } from "hono";
2621
2703
  import { imageUrlMessage, scratchpadDir, userText } from "@prismshadow/penguin-core";
2622
2704
 
@@ -2796,9 +2878,14 @@ var WorkspaceFilesService = class {
2796
2878
  );
2797
2879
  return unique.filter((_, i) => exists[i]);
2798
2880
  }
2799
- /** List a directory: dirs come first, each group sorted by name; kind follows the symlink target (consistent with read behavior). */
2881
+ /**
2882
+ * List a directory: dirs come first, each group sorted by name. Entries whose
2883
+ * canonical target leaves the Workspace are omitted, so listing cannot expose
2884
+ * metadata for an out-of-bounds symlink.
2885
+ */
2800
2886
  async list(workspace, rel) {
2801
2887
  const dir = await this.resolveRead(workspace, rel);
2888
+ const realBase = await this.realBase(workspace);
2802
2889
  let dirents;
2803
2890
  try {
2804
2891
  dirents = await fs6.readdir(dir, { withFileTypes: true });
@@ -2811,33 +2898,31 @@ var WorkspaceFilesService = class {
2811
2898
  }
2812
2899
  throw err;
2813
2900
  }
2814
- const entries = await Promise.all(
2901
+ const listed = await Promise.all(
2815
2902
  dirents.map(async (d) => {
2816
- let sizeBytes = 0;
2817
- let mtime = "";
2818
- let isDir = d.isDirectory();
2819
2903
  try {
2820
- const stat = await fs6.stat(path5.join(dir, d.name));
2821
- sizeBytes = stat.size;
2822
- mtime = stat.mtime.toISOString();
2823
- isDir = stat.isDirectory();
2904
+ const canonical = await fs6.realpath(path5.join(dir, d.name));
2905
+ this.assertInside(canonical, realBase);
2906
+ const stat = await fs6.stat(canonical);
2907
+ return {
2908
+ name: d.name,
2909
+ kind: stat.isDirectory() ? "dir" : "file",
2910
+ sizeBytes: stat.size,
2911
+ mtime: stat.mtime.toISOString()
2912
+ };
2824
2913
  } catch {
2914
+ return null;
2825
2915
  }
2826
- return {
2827
- name: d.name,
2828
- kind: isDir ? "dir" : "file",
2829
- sizeBytes,
2830
- mtime
2831
- };
2832
2916
  })
2833
2917
  );
2918
+ const entries = listed.filter((entry) => entry !== null);
2834
2919
  entries.sort(
2835
2920
  (a, b) => a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === "dir" ? -1 : 1
2836
2921
  );
2837
2922
  return { path: rel, entries };
2838
2923
  }
2839
2924
  /** Read a file (preview/download): IO on the canonical path (resolveRead has already eliminated symlink escapes). */
2840
- async read(workspace, rel) {
2925
+ async read(workspace, rel, options) {
2841
2926
  const file = await this.resolveRead(workspace, rel);
2842
2927
  let stat;
2843
2928
  try {
@@ -2846,16 +2931,35 @@ var WorkspaceFilesService = class {
2846
2931
  throw new HttpError(404, "path_not_found", "File does not exist.");
2847
2932
  }
2848
2933
  if (stat.isDirectory()) throw badRequest("path is a directory.");
2849
- if (stat.size > MAX_READ_BYTES) {
2934
+ const maxBytes = options?.maxBytes;
2935
+ if (maxBytes !== void 0 && (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_READ_BYTES)) {
2936
+ throw badRequest("maxBytes must be a positive integer within the read limit.");
2937
+ }
2938
+ if (maxBytes === void 0 && stat.size > MAX_READ_BYTES) {
2850
2939
  throw new HttpError(413, "file_too_large", "File exceeds the 50MB read limit.");
2851
2940
  }
2852
- const data = await fs6.readFile(file);
2941
+ let data;
2942
+ let truncated = false;
2943
+ if (maxBytes !== void 0 && stat.size > maxBytes) {
2944
+ const handle = await fs6.open(file, "r");
2945
+ try {
2946
+ const buffer = Buffer.alloc(maxBytes);
2947
+ const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0);
2948
+ data = buffer.subarray(0, bytesRead);
2949
+ truncated = true;
2950
+ } finally {
2951
+ await handle.close();
2952
+ }
2953
+ } else {
2954
+ data = await fs6.readFile(file);
2955
+ }
2853
2956
  const ext = path5.extname(file).toLowerCase();
2854
2957
  return {
2855
2958
  data,
2856
2959
  fileName: path5.basename(file),
2857
2960
  contentType: CONTENT_TYPES[ext] ?? "application/octet-stream",
2858
- scriptable: ext === ".html" || ext === ".htm" || ext === ".svg"
2961
+ scriptable: ext === ".html" || ext === ".htm" || ext === ".svg",
2962
+ ...truncated ? { truncated: true } : {}
2859
2963
  };
2860
2964
  }
2861
2965
  /**
@@ -2897,6 +3001,133 @@ var WorkspaceFilesService = class {
2897
3001
  }
2898
3002
  };
2899
3003
 
3004
+ // src/services/task-attachments.ts
3005
+ import fs7 from "fs/promises";
3006
+ import path6 from "path";
3007
+ import { randomBytes as randomBytes4 } from "crypto";
3008
+ import { appendAttachmentLines, attachedFileLine } from "@prismshadow/penguin-core";
3009
+ var MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
3010
+ var MAX_ATTACHMENT_COUNT = 20;
3011
+ var MAX_TOTAL_ATTACHMENT_BYTES = 12 * 1024 * 1024;
3012
+ var MAX_STEM_BYTES = 80;
3013
+ var WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
3014
+ var INVISIBLE_CHAR = new RegExp("\\p{C}", "u");
3015
+ function unsafeNameChar(ch) {
3016
+ if (/[A-Za-z0-9._-]/.test(ch)) return false;
3017
+ return ch.codePointAt(0) < 128 || INVISIBLE_CHAR.test(ch);
3018
+ }
3019
+ function sanitizeSegment(value) {
3020
+ return Array.from(value, (ch) => unsafeNameChar(ch) ? "-" : ch).join("");
3021
+ }
3022
+ function truncateBytes(value, maxBytes) {
3023
+ if (Buffer.byteLength(value) <= maxBytes) return value;
3024
+ let out = "";
3025
+ for (const ch of value) {
3026
+ if (Buffer.byteLength(out) + Buffer.byteLength(ch) > maxBytes) break;
3027
+ out += ch;
3028
+ }
3029
+ return out;
3030
+ }
3031
+ var MAX_NAME_ATTEMPTS = 16;
3032
+ function parseAttachmentPart(part, index) {
3033
+ const fileName = part.fileName;
3034
+ if (typeof fileName !== "string" || fileName.length === 0 || fileName.includes("/") || fileName.includes("\\") || fileName.includes("..") || fileName.includes("\0")) {
3035
+ throw badRequest(
3036
+ `input[${index}].fileName must be a non-empty file name without path separators or "..".`
3037
+ );
3038
+ }
3039
+ const dataUrl = part.dataUrl;
3040
+ const match = typeof dataUrl === "string" ? /^data:[^,]*;base64,([A-Za-z0-9+/=\s]+)$/.exec(dataUrl) : null;
3041
+ if (!match) {
3042
+ throw badRequest(`input[${index}].dataUrl must be a base64 data: URL of the file's bytes.`);
3043
+ }
3044
+ const bytes = Buffer.from(match[1], "base64");
3045
+ if (bytes.length === 0) {
3046
+ throw badRequest(`input[${index}].dataUrl decodes to an empty file.`);
3047
+ }
3048
+ if (bytes.length > MAX_ATTACHMENT_BYTES) {
3049
+ throw new HttpError(
3050
+ 413,
3051
+ "file_too_large",
3052
+ `Attached file exceeds the ${MAX_ATTACHMENT_BYTES / (1024 * 1024)}MB limit.`
3053
+ );
3054
+ }
3055
+ return { fileName, bytes };
3056
+ }
3057
+ function assertAttachmentBudget(attachments) {
3058
+ if (attachments.length > MAX_ATTACHMENT_COUNT) {
3059
+ throw new HttpError(
3060
+ 413,
3061
+ "too_many_files",
3062
+ `A message may carry at most ${MAX_ATTACHMENT_COUNT} attached files.`
3063
+ );
3064
+ }
3065
+ let total = 0;
3066
+ for (const a of attachments) total += a.bytes.length;
3067
+ if (total > MAX_TOTAL_ATTACHMENT_BYTES) {
3068
+ throw new HttpError(
3069
+ 413,
3070
+ "payload_too_large",
3071
+ `Attached files exceed the ${MAX_TOTAL_ATTACHMENT_BYTES / (1024 * 1024)}MB total limit for one message.`
3072
+ );
3073
+ }
3074
+ }
3075
+ function scratchpadName(fileName) {
3076
+ const ext = sanitizeSegment(path6.extname(fileName));
3077
+ const rawStem = fileName.slice(0, fileName.length - path6.extname(fileName).length);
3078
+ const stem = truncateBytes(sanitizeSegment(rawStem), MAX_STEM_BYTES).replace(/[. ]+$/, "");
3079
+ if (!stem || /^-+$/.test(stem)) return `file${ext}`;
3080
+ return WINDOWS_RESERVED.test(stem) ? `_${stem}${ext}` : `${stem}${ext}`;
3081
+ }
3082
+ async function writeAttachment(dir, attachment) {
3083
+ const base = scratchpadName(attachment.fileName);
3084
+ const ext = path6.extname(base);
3085
+ const stem = base.slice(0, base.length - ext.length);
3086
+ for (let attempt = 0; attempt < MAX_NAME_ATTEMPTS; attempt++) {
3087
+ const name = attempt === 0 ? base : `${stem}-${randomBytes4(3).toString("hex")}${ext}`;
3088
+ const file = path6.join(dir, name);
3089
+ try {
3090
+ await fs7.writeFile(file, attachment.bytes, { flag: "wx" });
3091
+ return file;
3092
+ } catch (err) {
3093
+ if (err.code !== "EEXIST") throw err;
3094
+ }
3095
+ }
3096
+ throw new Error(
3097
+ `failed to allocate a unique attachment file name under ${dir} after ${MAX_NAME_ATTEMPTS} attempts`
3098
+ );
3099
+ }
3100
+ async function openScratchpadDir(root, sessionId) {
3101
+ const dir = path6.join(root, sessionId);
3102
+ await fs7.mkdir(dir, { recursive: true });
3103
+ const canonicalRoot = await fs7.realpath(root);
3104
+ const rel = path6.relative(canonicalRoot, await fs7.realpath(dir));
3105
+ if (rel !== sessionId) {
3106
+ throw new Error(
3107
+ `session scratchpad ${dir} resolves outside the agent scratchpad root; refusing to write attachments`
3108
+ );
3109
+ }
3110
+ return dir;
3111
+ }
3112
+ async function removeAttachments(files) {
3113
+ await Promise.all(files.map((f) => fs7.rm(f, { force: true }).catch(() => {
3114
+ })));
3115
+ }
3116
+ async function attachFilesToInput(messages, attachments, root, sessionId) {
3117
+ if (attachments.length === 0) return { input: messages, written: [] };
3118
+ const dir = await openScratchpadDir(root, sessionId);
3119
+ const written = [];
3120
+ try {
3121
+ for (const attachment of attachments) {
3122
+ written.push(await writeAttachment(dir, attachment));
3123
+ }
3124
+ } catch (err) {
3125
+ await removeAttachments(written);
3126
+ throw err;
3127
+ }
3128
+ return { input: appendAttachmentLines(messages, written.map(attachedFileLine)), written };
3129
+ }
3130
+
2900
3131
  // src/http/routes/sessions.ts
2901
3132
  var SESSION_TITLE_MAX = 120;
2902
3133
  var STAT_MAX_PATHS = 100;
@@ -2914,12 +3145,33 @@ var SESSION_CATEGORIES = [
2914
3145
  "schedule",
2915
3146
  "archived"
2916
3147
  ];
3148
+ var IMAGE_DATA_URL = /^data:[^;,]+;base64,[A-Za-z0-9+/=\s]+$/;
3149
+ function requireImageUrl(url, field) {
3150
+ if (typeof url === "string") {
3151
+ if (url.startsWith("http://") || url.startsWith("https://")) return url;
3152
+ if (IMAGE_DATA_URL.test(url)) return url;
3153
+ }
3154
+ throw badRequest(
3155
+ `${field} must be an http(s) URL or a base64 data: URL (data:<mime>;base64,<bytes>).`
3156
+ );
3157
+ }
3158
+ function resolveScratchpadFile(dir, fileName) {
3159
+ if (!fileName || fileName === "." || fileName === "..") return null;
3160
+ for (const ch of fileName) {
3161
+ const code = ch.codePointAt(0);
3162
+ if (code < 32 || code === 127 || ch === "/" || ch === "\\") return null;
3163
+ }
3164
+ const resolved = path7.resolve(dir, fileName);
3165
+ return path7.dirname(resolved) === path7.resolve(dir) ? resolved : null;
3166
+ }
2917
3167
  function parseTaskInput(body) {
2918
3168
  const input = body.input;
2919
3169
  if (!Array.isArray(input) || input.length === 0) {
2920
3170
  throw badRequest("input must be an array with at least one item.");
2921
3171
  }
2922
- return input.map((item, i) => {
3172
+ const messages = [];
3173
+ const attachments = [];
3174
+ input.forEach((item, i) => {
2923
3175
  if (item === null || typeof item !== "object" || Array.isArray(item)) {
2924
3176
  throw badRequest(`input[${i}] must be an object.`);
2925
3177
  }
@@ -2928,17 +3180,27 @@ function parseTaskInput(body) {
2928
3180
  if (typeof part.text !== "string" || part.text.length === 0) {
2929
3181
  throw badRequest(`input[${i}].text must be a non-empty string.`);
2930
3182
  }
2931
- return userText(part.text);
3183
+ messages.push(userText(part.text));
3184
+ return;
2932
3185
  }
2933
3186
  if (part.type === "image_url") {
2934
- const url = part.imageUrl;
2935
- if (typeof url !== "string" || !(url.startsWith("data:") || url.startsWith("http://") || url.startsWith("https://"))) {
2936
- throw badRequest(`input[${i}].imageUrl only supports data: or http(s) URLs.`);
2937
- }
2938
- return imageUrlMessage(url);
3187
+ messages.push(imageUrlMessage(requireImageUrl(part.imageUrl, `input[${i}].imageUrl`)));
3188
+ return;
2939
3189
  }
2940
- throw badRequest(`input[${i}].type must be one of text / image_url.`);
3190
+ if (part.type === "file") {
3191
+ attachments.push(parseAttachmentPart(part, i));
3192
+ assertAttachmentBudget(attachments);
3193
+ return;
3194
+ }
3195
+ throw badRequest(`input[${i}].type must be one of text / image_url / file.`);
2941
3196
  });
3197
+ return { messages, attachments };
3198
+ }
3199
+ function parseSteerImages(body) {
3200
+ const images = body.images;
3201
+ if (images === void 0) return [];
3202
+ if (!Array.isArray(images)) throw badRequest("images must be an array.");
3203
+ return images.map((url, i) => requireImageUrl(url, `images[${i}]`));
2942
3204
  }
2943
3205
  function parseGoalField(body) {
2944
3206
  const goal = body.goal;
@@ -3101,8 +3363,8 @@ function sessionsRoutes(deps2) {
3101
3363
  ]);
3102
3364
  }
3103
3365
  await deps2.traceService.deleteSessionTraces(row.projectId, row.agentId, row.sessionId);
3104
- await fs7.rm(
3105
- path6.join(scratchpadDir(deps2.config.root, row.projectId, row.agentId), row.sessionId),
3366
+ await fs8.rm(
3367
+ path7.join(scratchpadDir(deps2.config.root, row.projectId, row.agentId), row.sessionId),
3106
3368
  { recursive: true, force: true }
3107
3369
  );
3108
3370
  deps2.sessionsRepo.deleteById(row.sessionId);
@@ -3116,17 +3378,14 @@ function sessionsRoutes(deps2) {
3116
3378
  app2.get("/:sessionId/scratchpad/:fileName", async (c) => {
3117
3379
  const row = resolveSession(c);
3118
3380
  const fileName = c.req.param("fileName") ?? "";
3119
- if (!/^[A-Za-z0-9._-]+$/.test(fileName) || fileName.includes("..")) {
3120
- throw new HttpError(404, "file_not_found", "File does not exist.");
3121
- }
3122
- const filePath = path6.join(
3123
- scratchpadDir(deps2.config.root, row.projectId, row.agentId),
3124
- row.sessionId,
3381
+ const filePath = resolveScratchpadFile(
3382
+ path7.join(scratchpadDir(deps2.config.root, row.projectId, row.agentId), row.sessionId),
3125
3383
  fileName
3126
3384
  );
3385
+ if (!filePath) throw new HttpError(404, "file_not_found", "File does not exist.");
3127
3386
  let bytes;
3128
3387
  try {
3129
- bytes = await fs7.readFile(filePath);
3388
+ bytes = await fs8.readFile(filePath);
3130
3389
  } catch {
3131
3390
  throw new HttpError(404, "file_not_found", "File does not exist.");
3132
3391
  }
@@ -3137,9 +3396,21 @@ function sessionsRoutes(deps2) {
3137
3396
  ".gif": "image/gif",
3138
3397
  ".webp": "image/webp"
3139
3398
  };
3140
- const mime = MIME_BY_EXT[path6.extname(fileName).toLowerCase()] ?? "application/octet-stream";
3399
+ const mime = MIME_BY_EXT[path7.extname(fileName).toLowerCase()];
3141
3400
  return c.body(new Uint8Array(bytes), 200, {
3142
- "content-type": mime,
3401
+ "content-type": mime ?? "application/octet-stream",
3402
+ // nosniff: the composer's file attachments land in this same directory, so the bytes
3403
+ // here are arbitrary user content served from the App's own origin — without it a
3404
+ // browser could sniff an `application/octet-stream` upload back into HTML and run it
3405
+ // same-origin (the same defense workspace file reads apply).
3406
+ "x-content-type-options": "nosniff",
3407
+ // Second, independent layer for everything that fell off the allowlist: the only reason
3408
+ // this endpoint is fetched inline is the conversation's <img> tags, so anything that is
3409
+ // not one of those images is served as a download and never renders as a document —
3410
+ // nosniff alone would be the whole defense otherwise.
3411
+ ...mime === void 0 ? {
3412
+ "content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`
3413
+ } : {},
3143
3414
  "cache-control": "private, max-age=31536000, immutable"
3144
3415
  });
3145
3416
  });
@@ -3182,32 +3453,53 @@ function sessionsRoutes(deps2) {
3182
3453
  const goal = parseGoalField(body);
3183
3454
  const thinkingLevel = optionalEnum(body, "thinkingLevel", THINKING_LEVELS);
3184
3455
  if (goal) {
3185
- const input2 = parseTaskInput(body);
3186
- const text = input2.filter((m) => m.payload.type === "text").map((m) => m.payload.text).join("\n").trim();
3187
- if (!text || input2.some((m) => m.payload.type !== "text")) {
3188
- throw badRequest("goal mode requires text-only input (the objective).");
3456
+ const { messages, attachments } = parseTaskInput(body);
3457
+ const text = messages.filter((m) => m.payload.type === "text").map((m) => m.payload.text).join("\n").trim();
3458
+ if (!text) {
3459
+ throw badRequest("goal mode requires a non-empty text objective.");
3460
+ }
3461
+ if (attachments.length > 0) {
3462
+ throw badRequest("goal mode accepts text and images only (no file attachments).");
3189
3463
  }
3190
- const { sessionId: sessionId2 } = await deps2.manager.startGoal(row.sessionId, {
3191
- input: input2,
3464
+ const { sessionId } = await deps2.manager.startGoal(row.sessionId, {
3465
+ input: messages,
3192
3466
  budget: goal.budget,
3193
3467
  ...thinkingLevel !== void 0 ? { thinkingLevel } : {}
3194
3468
  });
3195
- return c.json({ sessionId: sessionId2 }, 202);
3469
+ return c.json({ sessionId }, 202);
3196
3470
  }
3197
- const input = parseTaskInput(body);
3471
+ const parsed = parseTaskInput(body);
3198
3472
  const queueIfBusy = body.queueIfBusy === true;
3199
- const { sessionId, queued } = await deps2.manager.startTask(row.sessionId, input, {
3200
- ...thinkingLevel !== void 0 ? { thinkingLevel } : {},
3201
- queueIfBusy
3202
- });
3203
- return c.json({ sessionId, queued }, 202);
3473
+ deps2.manager.assertCanAcceptTask(row.sessionId, { queueIfBusy });
3474
+ const { input, written } = await attachFilesToInput(
3475
+ parsed.messages,
3476
+ parsed.attachments,
3477
+ scratchpadDir(deps2.config.root, row.projectId, row.agentId),
3478
+ row.sessionId
3479
+ );
3480
+ try {
3481
+ const { sessionId, queued } = await deps2.manager.startTask(row.sessionId, input, {
3482
+ ...thinkingLevel !== void 0 ? { thinkingLevel } : {},
3483
+ queueIfBusy
3484
+ });
3485
+ return c.json({ sessionId, queued }, 202);
3486
+ } catch (err) {
3487
+ await removeAttachments(written);
3488
+ throw err;
3489
+ }
3204
3490
  });
3205
3491
  app2.post("/:sessionId/steer", async (c) => {
3206
3492
  const row = resolveSession(c);
3207
3493
  const body = await readJson(c);
3208
3494
  const text = typeof body.text === "string" ? body.text.trim() : "";
3209
- if (!text) throw badRequest("text must be a non-empty string.");
3210
- deps2.manager.steer(row.sessionId, text);
3495
+ const images = parseSteerImages(body);
3496
+ if (!text && images.length === 0) {
3497
+ throw badRequest("text or images must carry the steering message.");
3498
+ }
3499
+ deps2.manager.steer(row.sessionId, [
3500
+ ...text ? [userText(text)] : [],
3501
+ ...images.map((url) => imageUrlMessage(url))
3502
+ ]);
3211
3503
  return c.body(null, 202);
3212
3504
  });
3213
3505
  app2.get("/:sessionId/goal", (c) => {
@@ -3661,8 +3953,8 @@ var ErrorRecorder = class {
3661
3953
  };
3662
3954
 
3663
3955
  // src/runtime/session-manager.ts
3664
- import fs8 from "fs/promises";
3665
- import path8 from "path";
3956
+ import fs9 from "fs/promises";
3957
+ import path9 from "path";
3666
3958
  import {
3667
3959
  createAgent,
3668
3960
  findLatestTraceFile,
@@ -3922,14 +4214,26 @@ var SessionSources = class {
3922
4214
 
3923
4215
  // src/runtime/stream-error-watcher.ts
3924
4216
  import { isEventMessage, isModelMessage, isSessionMeta } from "@prismshadow/penguin-core";
3925
- import path7 from "path";
4217
+ import path8 from "path";
3926
4218
  var TOOL_NAMES_MAX = 1e3;
3927
4219
  var ORIGIN_CTX_MAX = 200;
3928
4220
  var LLM_FAILURES = {
3929
- failed: { code: "llm_failed", kind: "unexpected", text: "LLM request failed (not retryable)." },
3930
- // Credentials rejection shares the failed bucket (no new taxonomy): same code/kind, the
3931
- // real reason arrives via the abort reason or the event's own message as usual.
3932
- auth: { code: "llm_failed", kind: "unexpected", text: "LLM request failed (not retryable)." },
4221
+ // Reached here only when no further attempt followed: the ladder ran out (or the run ended
4222
+ // on it), so the user lost the turn and someone should look. A `failed` that was retried
4223
+ // takes LLM_FAILED_RETRIED instead.
4224
+ failed: {
4225
+ code: "llm_failed",
4226
+ kind: "unexpected",
4227
+ text: "LLM request failed and the retries did not recover it."
4228
+ },
4229
+ // Its own code, not the failed bucket: dedup is `(source, code, Project)` over a short
4230
+ // window, and `llm_failed_retried` can now fire on any recovered blip — sharing a bucket
4231
+ // would let a genuine credential failure be dropped as a duplicate of one.
4232
+ auth: {
4233
+ code: "llm_auth",
4234
+ kind: "unexpected",
4235
+ text: "LLM request rejected: the provider did not accept the credentials."
4236
+ },
3933
4237
  timeout: {
3934
4238
  code: "llm_timeout",
3935
4239
  kind: "expected",
@@ -3941,12 +4245,23 @@ var LLM_FAILURES = {
3941
4245
  text: "LLM response could not be parsed (the engine reconnects and retries)."
3942
4246
  }
3943
4247
  };
4248
+ var LLM_FAILED_RETRIED = {
4249
+ code: "llm_failed_retried",
4250
+ kind: "expected",
4251
+ text: "LLM request failed (the engine reconnects and retries)."
4252
+ };
3944
4253
  function isLlmFailure(s) {
3945
4254
  return s === "failed" || s === "timeout" || s === "malformed" || s === "auth";
3946
4255
  }
3947
4256
  function isToolFailure(s) {
3948
4257
  return s === "failed" || s === "timeout";
3949
4258
  }
4259
+ var COMMAND_TOOLS = /* @__PURE__ */ new Set(["exec_command", "input_command"]);
4260
+ var ORDINARY_EXIT_NOTE = /\[exit code: [^\]\n]*\]\s*$/;
4261
+ function isOrdinaryCommandExit(name, output) {
4262
+ if (name !== void 0 && !COMMAND_TOOLS.has(name)) return false;
4263
+ return ORDINARY_EXIT_NOTE.test(output);
4264
+ }
3950
4265
  function isUserAbortReason(reason) {
3951
4266
  return /^aborted\b/i.test(reason);
3952
4267
  }
@@ -4004,7 +4319,7 @@ var StreamErrorWatcher = class {
4004
4319
  registerOrigin(msg) {
4005
4320
  const key = originKey(msg);
4006
4321
  if (!key) return;
4007
- const agentId = path7.basename(path7.dirname(msg.payload.agent_state));
4322
+ const agentId = path8.basename(path8.dirname(msg.payload.agent_state));
4008
4323
  if (!agentId || agentId === "." || agentId === "..") return;
4009
4324
  this.originCtx.delete(key);
4010
4325
  this.originCtx.set(key, { agentId, sessionId: key });
@@ -4041,7 +4356,7 @@ var StreamErrorWatcher = class {
4041
4356
  return;
4042
4357
  }
4043
4358
  if (p.type === "request_begin") {
4044
- this.flush(key);
4359
+ this.flush(key, null, true);
4045
4360
  return;
4046
4361
  }
4047
4362
  if (p.type === "abort") {
@@ -4050,15 +4365,21 @@ var StreamErrorWatcher = class {
4050
4365
  }
4051
4366
  /**
4052
4367
  * Persist a pending LLM failure (no-op if none is pending); `reason` is the abort
4053
- * message that arrived afterward. Pending state is already bucketed by origin, so
4368
+ * message that arrived afterward, and `retried` says whether another attempt followed
4369
+ * (only a `request_begin` proves that). Pending state is already bucketed by origin, so
4054
4370
  * `key` is exactly "the session that produced this failure" — attribution is looked
4055
4371
  * up from it (see file header).
4372
+ *
4373
+ * `retried` defaults to false so every other resolution — an abort, the defensive flush at
4374
+ * the next request_end, or close() at the end of the run — is read as "nothing came after
4375
+ * this failure". That is the conservative direction: at worst a recovered blip is escalated,
4376
+ * never a lost turn silently downgraded.
4056
4377
  */
4057
- flush(key, reason) {
4378
+ flush(key, reason, retried = false) {
4058
4379
  const entry = this.pending.get(key);
4059
4380
  if (entry === void 0) return;
4060
4381
  this.pending.delete(key);
4061
- const spec = LLM_FAILURES[entry.status];
4382
+ const spec = retried && entry.status === "failed" ? LLM_FAILED_RETRIED : LLM_FAILURES[entry.status];
4062
4383
  const trimmed = reason?.trim();
4063
4384
  const message = trimmed && !isUserAbortReason(trimmed) ? trimmed : entry.message ?? spec.text;
4064
4385
  this.errors.record({
@@ -4088,9 +4409,11 @@ var StreamErrorWatcher = class {
4088
4409
  const name = this.toolNames.get(key);
4089
4410
  this.toolNames.delete(key);
4090
4411
  if (!isToolFailure(p.stop_reason)) return;
4412
+ const output = p.output ?? "";
4413
+ if (isOrdinaryCommandExit(name, output)) return;
4091
4414
  this.errors.record({
4092
4415
  source: "environment",
4093
- err: toolFailureText(p.output ?? ""),
4416
+ err: toolFailureText(output),
4094
4417
  ctx: this.ctxFor(origin),
4095
4418
  // The tool name goes into code: so the stats dashboard's "most common error code" and table can show which tool failed.
4096
4419
  code: `tool_${p.stop_reason}:${name ?? "unknown"}`,
@@ -4129,7 +4452,7 @@ function createCoreSessionLoader(root, sources) {
4129
4452
  }
4130
4453
  }
4131
4454
  try {
4132
- const stat = await fs8.stat(row.workspace);
4455
+ const stat = await fs9.stat(row.workspace);
4133
4456
  if (!stat.isDirectory()) throw new Error("not a directory");
4134
4457
  } catch {
4135
4458
  throw new HttpError(
@@ -4305,6 +4628,23 @@ var SessionManager = class {
4305
4628
  for (const agentId of agentIds) this.invalidateAgentRuntimes(projectId, agentId);
4306
4629
  }
4307
4630
  // —— Task / compaction drive ——
4631
+ /**
4632
+ * Cheap, lock-free rehearsal of the 409/503 conditions startTask checks, throwing exactly the
4633
+ * same HttpErrors. **Advisory only**: it neither takes the Session lock nor loads an entry, so
4634
+ * a session that isn't in the active table reads as acceptable and a status change racing this
4635
+ * call is not caught — the authoritative check is still the one inside startTask.
4636
+ *
4637
+ * It exists so a caller that has irreversible work to do first (POST /tasks writes the
4638
+ * message's file attachments to disk) can find out about the ordinary "a Task is already
4639
+ * running" rejection before doing it, instead of undoing it afterwards.
4640
+ */
4641
+ assertCanAcceptTask(sessionId, opts) {
4642
+ this.assertOpen();
4643
+ this.assertAgentNotDeleting(sessionId);
4644
+ this.assertSessionNotDeleting(sessionId);
4645
+ const entry = this.entries.get(sessionId);
4646
+ if (entry && !opts?.queueIfBusy) this.assertIdle(entry);
4647
+ }
4308
4648
  /**
4309
4649
  * Start a Task: get-or-load → 409
4310
4650
  * mutual-exclusion check → publish the input messages first → drive run in the
@@ -4543,15 +4883,16 @@ var SessionManager = class {
4543
4883
  return entry.approvals.decide(toolCallId, decision);
4544
4884
  }
4545
4885
  /**
4546
- * Mid-run steering: forward the text to the running Session (core delivers it between
4547
- * turns as a standalone `[user_steering]` user message — no SSE event of its own; the
4548
- * message arrives through the stream the drive loop already publishes). 409 when the
4549
- * Session isn't running a Task (idle / compacting / not loaded) or the run finished in the
4550
- * race window — the caller falls back to submitting a normal task.
4886
+ * Mid-run steering: forward the message to the running Session (core delivers it between
4887
+ * turns as a standalone `[user_steering]` user message followed by its images — no SSE
4888
+ * event of its own; the messages arrive through the stream the drive loop already
4889
+ * publishes). 409 when the Session isn't running a Task (idle / compacting / not loaded)
4890
+ * or the run finished in the race window — the caller falls back to submitting a normal
4891
+ * task, which carries the same text and images.
4551
4892
  */
4552
- steer(sessionId, text) {
4893
+ steer(sessionId, input) {
4553
4894
  const entry = this.entries.get(sessionId);
4554
- if (!entry || entry.status !== "running" || !entry.session.steer(text)) {
4895
+ if (!entry || entry.status !== "running" || !entry.session.steer(input)) {
4555
4896
  throw new HttpError(
4556
4897
  409,
4557
4898
  "not_running",
@@ -4915,7 +5256,7 @@ var SessionManager = class {
4915
5256
  const childSid = msg.origin[msg.origin.length - 1];
4916
5257
  if (children.has(childSid)) return null;
4917
5258
  const p = msg.payload;
4918
- const agentId = path8.basename(path8.dirname(p.agent_state));
5259
+ const agentId = path9.basename(path9.dirname(p.agent_state));
4919
5260
  if (!agentId || agentId === "." || agentId === "..") return null;
4920
5261
  const source = asSessionSource(p.source) ?? "subagent";
4921
5262
  this.deps.sources.set(childSid, source);
@@ -5394,7 +5735,7 @@ var UsageRecorder = class {
5394
5735
  };
5395
5736
 
5396
5737
  // src/services/ids.ts
5397
- import { randomBytes as randomBytes4 } from "crypto";
5738
+ import { randomBytes as randomBytes5 } from "crypto";
5398
5739
  var SEMANTIC_ID_PATTERN = /^[a-z][a-z0-9_]{1,63}$/;
5399
5740
  var USERNAME_PATTERN = /^[a-z][a-z0-9_]{1,31}$/;
5400
5741
  var PROJECT_SUFFIX_PATTERN = /^[a-z0-9_]+$/;
@@ -5470,7 +5811,7 @@ var AdminService = class {
5470
5811
  };
5471
5812
 
5472
5813
  // src/services/agent-config-service.ts
5473
- import fs10 from "fs/promises";
5814
+ import fs11 from "fs/promises";
5474
5815
  import { parseDocument, parse as parseYaml } from "yaml";
5475
5816
  import {
5476
5817
  agentsMdPath,
@@ -5485,8 +5826,8 @@ import {
5485
5826
  } from "@prismshadow/penguin-core";
5486
5827
 
5487
5828
  // src/services/project-config-service.ts
5488
- import fs9 from "fs/promises";
5489
- import path9 from "path";
5829
+ import fs10 from "fs/promises";
5830
+ import path10 from "path";
5490
5831
  import { parse as parseToml2 } from "smol-toml";
5491
5832
  import {
5492
5833
  GenerativeModel,
@@ -5545,7 +5886,7 @@ var ProjectConfigService = class {
5545
5886
  */
5546
5887
  async configUpdatedAt(projectId) {
5547
5888
  try {
5548
- const st = await fs9.stat(this.filePath(projectId));
5889
+ const st = await fs10.stat(this.filePath(projectId));
5549
5890
  return st.mtime.toISOString();
5550
5891
  } catch {
5551
5892
  return void 0;
@@ -5555,7 +5896,7 @@ var ProjectConfigService = class {
5555
5896
  async readRaw(projectId) {
5556
5897
  let raw;
5557
5898
  try {
5558
- raw = await fs9.readFile(this.filePath(projectId), "utf8");
5899
+ raw = await fs10.readFile(this.filePath(projectId), "utf8");
5559
5900
  } catch (err) {
5560
5901
  if (err.code === "ENOENT") return {};
5561
5902
  throw err;
@@ -5570,9 +5911,9 @@ var ProjectConfigService = class {
5570
5911
  */
5571
5912
  async writeRaw(projectId, data) {
5572
5913
  const file = this.filePath(projectId);
5573
- await fs9.mkdir(path9.dirname(file), { recursive: true });
5574
- await fs9.writeFile(file, renderProjectConfigToml(data), { encoding: "utf8", mode: 384 });
5575
- await fs9.chmod(file, 384);
5914
+ await fs10.mkdir(path10.dirname(file), { recursive: true });
5915
+ await fs10.writeFile(file, renderProjectConfigToml(data), { encoding: "utf8", mode: 384 });
5916
+ await fs10.chmod(file, 384);
5576
5917
  }
5577
5918
  /**
5578
5919
  * Initial config for a newly created Project: display name + preset built-in
@@ -5616,6 +5957,15 @@ var ProjectConfigService = class {
5616
5957
  const raw = await this.readRaw(projectId);
5617
5958
  return typeof raw.name === "string" ? raw.name : void 0;
5618
5959
  }
5960
+ /**
5961
+ * Rewrites the display name, preserving every other field (models, credentials, default
5962
+ * model): read-modify-write of the same toml, like ensurePresetModels. The id itself is
5963
+ * immutable — only this label changes.
5964
+ */
5965
+ async setName(projectId, name) {
5966
+ const raw = await this.readRaw(projectId);
5967
+ await this.writeRaw(projectId, { ...raw, name });
5968
+ }
5619
5969
  /** Paired reference of the default Model; returns undefined if unconfigured (or in the old string format). */
5620
5970
  async getDefaultModelRef(projectId) {
5621
5971
  const raw = await this.readRaw(projectId);
@@ -5936,7 +6286,7 @@ var AgentConfigService = class {
5936
6286
  /** Whether the Agent exists (determined by the presence of system_config.yaml, matching the CLI's convention). */
5937
6287
  async exists(projectId, agentId) {
5938
6288
  try {
5939
- await fs10.access(systemConfigPath(this.root, projectId, agentId));
6289
+ await fs11.access(systemConfigPath(this.root, projectId, agentId));
5940
6290
  return true;
5941
6291
  } catch {
5942
6292
  return false;
@@ -5954,7 +6304,7 @@ var AgentConfigService = class {
5954
6304
  */
5955
6305
  async readCardMeta(projectId, agentId) {
5956
6306
  try {
5957
- const raw = await fs10.readFile(systemConfigPath(this.root, projectId, agentId), "utf8");
6307
+ const raw = await fs11.readFile(systemConfigPath(this.root, projectId, agentId), "utf8");
5958
6308
  const parsed = asRecord(parseYaml(raw));
5959
6309
  const tools = asRecord(parsed.tools);
5960
6310
  const countOf = (v) => Array.isArray(v) ? v.length : 0;
@@ -5972,14 +6322,14 @@ var AgentConfigService = class {
5972
6322
  async getConfig(projectId, agentId) {
5973
6323
  await this.requireExists(projectId, agentId);
5974
6324
  const yamlPath = systemConfigPath(this.root, projectId, agentId);
5975
- const systemConfigYaml = await fs10.readFile(yamlPath, "utf8");
6325
+ const systemConfigYaml = await fs11.readFile(yamlPath, "utf8");
5976
6326
  const parsed = asRecord(parseYaml(systemConfigYaml));
5977
6327
  const model = asRecord(parsed.model);
5978
6328
  const compaction = asRecord(parsed.compaction);
5979
6329
  const tools = asRecord(parsed.tools);
5980
6330
  let agentsMd = "";
5981
6331
  try {
5982
- agentsMd = await fs10.readFile(agentsMdPath(this.root, projectId, agentId), "utf8");
6332
+ agentsMd = await fs11.readFile(agentsMdPath(this.root, projectId, agentId), "utf8");
5983
6333
  } catch {
5984
6334
  }
5985
6335
  const modelDto = {
@@ -6022,7 +6372,7 @@ var AgentConfigService = class {
6022
6372
  await this.applyConfigUpdate(projectId, agentId, req.config);
6023
6373
  }
6024
6374
  if (req.agentsMd !== void 0) {
6025
- await fs10.writeFile(agentsMdPath(this.root, projectId, agentId), req.agentsMd, "utf8");
6375
+ await fs11.writeFile(agentsMdPath(this.root, projectId, agentId), req.agentsMd, "utf8");
6026
6376
  }
6027
6377
  }
6028
6378
  /**
@@ -6040,9 +6390,9 @@ var AgentConfigService = class {
6040
6390
  async applyConfigUpdate(projectId, agentId, config2) {
6041
6391
  const cfg = config2;
6042
6392
  const yamlPath = systemConfigPath(this.root, projectId, agentId);
6043
- const doc = parseDocument(await fs10.readFile(yamlPath, "utf8"));
6044
- const setIfProvided = (path17, value) => {
6045
- if (value !== void 0) doc.setIn(path17, value);
6393
+ const doc = parseDocument(await fs11.readFile(yamlPath, "utf8"));
6394
+ const setIfProvided = (path18, value) => {
6395
+ if (value !== void 0) doc.setIn(path18, value);
6046
6396
  };
6047
6397
  setIfProvided(["name"], optionalString(cfg, "name", { maxLen: 100, label: "name" }));
6048
6398
  setIfProvided(
@@ -6091,7 +6441,7 @@ var AgentConfigService = class {
6091
6441
  if (cfg.mcpServers !== void 0) {
6092
6442
  doc.setIn(["tools", "mcpServers"], validateMcpServers(cfg.mcpServers));
6093
6443
  }
6094
- await fs10.writeFile(yamlPath, doc.toString(), "utf8");
6444
+ await fs11.writeFile(yamlPath, doc.toString(), "utf8");
6095
6445
  }
6096
6446
  /** Read the Agent vault (agent_state/.vault.toml): values are always masked, plaintext is never sent to the client. */
6097
6447
  async getVault(projectId, agentId) {
@@ -6188,8 +6538,8 @@ function validateMcpServers(value) {
6188
6538
  }
6189
6539
 
6190
6540
  // src/services/agent-service.ts
6191
- import fs11 from "fs/promises";
6192
- import path10 from "path";
6541
+ import fs12 from "fs/promises";
6542
+ import path11 from "path";
6193
6543
  import {
6194
6544
  agentDir,
6195
6545
  agentsDir as agentsDir2,
@@ -6216,7 +6566,7 @@ var AgentService = class {
6216
6566
  const known = new Map(this.agents.list(projectId).map((r) => [r.agentId, r]));
6217
6567
  let entries = [];
6218
6568
  try {
6219
- const dirents = await fs11.readdir(agentsDir2(this.root, projectId), { withFileTypes: true });
6569
+ const dirents = await fs12.readdir(agentsDir2(this.root, projectId), { withFileTypes: true });
6220
6570
  entries = dirents.filter((d) => d.isDirectory()).map((d) => d.name);
6221
6571
  } catch {
6222
6572
  }
@@ -6225,7 +6575,7 @@ var AgentService = class {
6225
6575
  const configPath = systemConfigPath2(this.root, projectId, agentId);
6226
6576
  let createdAt;
6227
6577
  try {
6228
- const stat = await fs11.stat(configPath);
6578
+ const stat = await fs12.stat(configPath);
6229
6579
  createdAt = (stat.birthtime.getTime() > 0 ? stat.birthtime : stat.mtime).toISOString();
6230
6580
  } catch {
6231
6581
  continue;
@@ -6269,7 +6619,7 @@ var AgentService = class {
6269
6619
  /** Number of scheduled tasks: count of .toml files under schedule/ (0 if the directory doesn't exist). */
6270
6620
  async scheduleCount(projectId, agentId) {
6271
6621
  try {
6272
- const names = await fs11.readdir(scheduleDir2(this.root, projectId, agentId));
6622
+ const names = await fs12.readdir(scheduleDir2(this.root, projectId, agentId));
6273
6623
  return names.filter((n) => n.endsWith(".toml")).length;
6274
6624
  } catch {
6275
6625
  return 0;
@@ -6280,14 +6630,14 @@ var AgentService = class {
6280
6630
  const base = skillsDir2(this.root, projectId, agentId);
6281
6631
  let dirents;
6282
6632
  try {
6283
- dirents = await fs11.readdir(base, { withFileTypes: true });
6633
+ dirents = await fs12.readdir(base, { withFileTypes: true });
6284
6634
  } catch {
6285
6635
  return 0;
6286
6636
  }
6287
6637
  const present = await Promise.all(
6288
6638
  dirents.filter((d) => d.isDirectory()).map(async (d) => {
6289
6639
  try {
6290
- await fs11.access(path10.join(base, d.name, "SKILL.md"));
6640
+ await fs12.access(path11.join(base, d.name, "SKILL.md"));
6291
6641
  return true;
6292
6642
  } catch {
6293
6643
  return false;
@@ -6305,7 +6655,7 @@ var AgentService = class {
6305
6655
  const times = await Promise.all(
6306
6656
  paths.map(async (p) => {
6307
6657
  try {
6308
- return (await fs11.stat(p)).mtime.getTime();
6658
+ return (await fs12.stat(p)).mtime.getTime();
6309
6659
  } catch {
6310
6660
  return 0;
6311
6661
  }
@@ -6330,7 +6680,7 @@ var AgentService = class {
6330
6680
  "Built-in Agents (default_agent) are provisioned with the Project and cannot be deleted from the web."
6331
6681
  );
6332
6682
  }
6333
- await fs11.rm(agentDir(this.root, projectId, agentId), { recursive: true, force: true });
6683
+ await fs12.rm(agentDir(this.root, projectId, agentId), { recursive: true, force: true });
6334
6684
  this.agents.delete(projectId, agentId);
6335
6685
  }
6336
6686
  /**
@@ -6347,7 +6697,7 @@ var AgentService = class {
6347
6697
  `Agent id must be 2\u201364 characters: ${SEMANTIC_ID_RULE}.`
6348
6698
  );
6349
6699
  }
6350
- const taken = this.agents.exists(projectId, agentId) || await fs11.stat(agentDir(this.root, projectId, agentId)).then(
6700
+ const taken = this.agents.exists(projectId, agentId) || await fs12.stat(agentDir(this.root, projectId, agentId)).then(
6351
6701
  () => true,
6352
6702
  () => false
6353
6703
  );
@@ -6361,7 +6711,7 @@ var AgentService = class {
6361
6711
  config: { name: displayName, ...description !== void 0 ? { description } : {} }
6362
6712
  });
6363
6713
  } catch (err) {
6364
- await fs11.rm(agentDir(this.root, projectId, agentId), { recursive: true, force: true }).catch(() => {
6714
+ await fs12.rm(agentDir(this.root, projectId, agentId), { recursive: true, force: true }).catch(() => {
6365
6715
  });
6366
6716
  throw err;
6367
6717
  }
@@ -6385,120 +6735,205 @@ var AgentService = class {
6385
6735
  };
6386
6736
 
6387
6737
  // src/services/benchmark-service.ts
6388
- import fs12 from "fs/promises";
6389
- import path11 from "path";
6738
+ import fs13 from "fs/promises";
6739
+ import path12 from "path";
6390
6740
  import { parse as parseToml3 } from "smol-toml";
6391
6741
  import { parse as parseYaml2 } from "yaml";
6392
6742
  import { benchmarksDir } from "@prismshadow/penguin-core";
6743
+ var STATEMENT_TITLE_READ_BYTES = 64 * 1024;
6393
6744
  function asRecord2(v) {
6394
6745
  return v !== null && typeof v === "object" && !Array.isArray(v) ? v : {};
6395
6746
  }
6396
6747
  function numberOr(v) {
6397
6748
  return typeof v === "number" && Number.isFinite(v) ? v : void 0;
6398
6749
  }
6750
+ function scoreOr(v) {
6751
+ const value = numberOr(v);
6752
+ return value !== void 0 && value >= 0 && value <= 100 ? value : void 0;
6753
+ }
6754
+ function nonNegativeOr(v) {
6755
+ const value = numberOr(v);
6756
+ return value !== void 0 && value >= 0 ? value : void 0;
6757
+ }
6758
+ function nonNegativeIntegerOr(v) {
6759
+ const value = nonNegativeOr(v);
6760
+ return value !== void 0 && Number.isInteger(value) ? value : void 0;
6761
+ }
6762
+ function nullableCostOr(v) {
6763
+ if (v === null) return null;
6764
+ return nonNegativeOr(v);
6765
+ }
6399
6766
  function stringOr(v) {
6400
6767
  return typeof v === "string" && v !== "" ? v : void 0;
6401
6768
  }
6769
+ function isWithin(parent, child) {
6770
+ const relative = path12.relative(parent, child);
6771
+ return relative !== "" && !relative.startsWith("..") && !path12.isAbsolute(relative);
6772
+ }
6773
+ function statementTitle(statement, fallback) {
6774
+ const heading = /^#\s+(.+)$/m.exec(statement)?.[1]?.trim();
6775
+ return heading?.replace(/^Case\s+\d+\s*:\s*/i, "") || fallback;
6776
+ }
6777
+ async function readStatementTitle(readme, fallback) {
6778
+ const handle = await fs13.open(readme, "r");
6779
+ try {
6780
+ const buffer = Buffer.alloc(STATEMENT_TITLE_READ_BYTES);
6781
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
6782
+ return statementTitle(buffer.subarray(0, bytesRead).toString("utf8"), fallback);
6783
+ } finally {
6784
+ await handle.close();
6785
+ }
6786
+ }
6402
6787
  function toRun(v) {
6403
6788
  const r = asRecord2(v);
6404
- const score = numberOr(r.score);
6405
- if (score === void 0) return null;
6406
- const cost = numberOr(r.cost);
6407
- const durationMs = numberOr(r.duration_ms);
6789
+ const score = scoreOr(r.score);
6790
+ const cost = nullableCostOr(r.cost);
6791
+ const durationMs = nonNegativeIntegerOr(r.duration_ms);
6408
6792
  const sessionId = stringOr(r.session_id);
6409
- return {
6410
- score,
6411
- ...cost !== void 0 ? { cost } : {},
6412
- ...durationMs !== void 0 ? { durationMs } : {},
6413
- ...sessionId !== void 0 ? { sessionId } : {}
6414
- };
6415
- }
6416
- function averageOf(runs, pick) {
6417
- const values = runs.map(pick).filter((v) => v !== void 0);
6418
- if (values.length === 0) return void 0;
6419
- return values.reduce((a, b) => a + b, 0) / values.length;
6793
+ if (score === void 0 || cost === void 0 || durationMs === void 0 || !sessionId)
6794
+ return null;
6795
+ return { score, cost, durationMs, sessionId };
6420
6796
  }
6421
6797
  function toCase(v) {
6422
6798
  const cr = asRecord2(v);
6423
6799
  const caseId = stringOr(cr.case);
6424
- if (caseId === void 0) return null;
6425
- const parsedRuns = Array.isArray(cr.runs) ? cr.runs.map(toRun).filter((r) => r !== null) : [];
6426
- const score = numberOr(cr.score) ?? averageOf(parsedRuns, (r) => r.score);
6427
- if (score === void 0) return null;
6428
- const cost = numberOr(cr.cost) ?? averageOf(parsedRuns, (r) => r.cost);
6429
- const durationMs = numberOr(cr.duration_ms) ?? averageOf(parsedRuns, (r) => r.durationMs);
6430
- const sessionId = stringOr(cr.session_id);
6431
- const runs = parsedRuns.length > 0 ? parsedRuns : [
6432
- // The old format is parsed as a single run: the case-level values are that run's raw result.
6433
- {
6434
- score,
6435
- ...cost !== void 0 ? { cost } : {},
6436
- ...durationMs !== void 0 ? { durationMs } : {},
6437
- ...sessionId !== void 0 ? { sessionId } : {}
6438
- }
6439
- ];
6800
+ const score = scoreOr(cr.score);
6801
+ const cost = nullableCostOr(cr.cost);
6802
+ const durationMs = nonNegativeIntegerOr(cr.duration_ms);
6803
+ if (!caseId || score === void 0 || cost === void 0 || durationMs === void 0 || "max_score" in cr || !Array.isArray(cr.runs) || cr.runs.length === 0) {
6804
+ return null;
6805
+ }
6806
+ const parsedRuns = cr.runs.map(toRun);
6807
+ if (parsedRuns.some((run) => run === null)) return null;
6808
+ const runs = parsedRuns;
6440
6809
  return {
6441
6810
  case: caseId,
6442
6811
  score,
6443
- ...cost !== void 0 ? { cost } : {},
6444
- ...durationMs !== void 0 ? { durationMs } : {},
6445
- ...sessionId !== void 0 ? { sessionId } : {},
6812
+ cost,
6813
+ durationMs,
6446
6814
  runs
6447
6815
  };
6448
6816
  }
6449
6817
  function toEvaluation(v) {
6450
6818
  const r = asRecord2(v);
6451
6819
  const time = r.time instanceof Date ? r.time.toISOString() : r.time;
6452
- const score = numberOr(r.score);
6453
- if (typeof time !== "string" || time === "" || score === void 0) return null;
6454
- const cases = Array.isArray(r.cases) ? r.cases.map(toCase).filter((c) => c !== null) : [];
6820
+ const score = scoreOr(r.score);
6821
+ const cost = nullableCostOr(r.cost);
6822
+ const durationMs = nonNegativeIntegerOr(r.duration_ms);
6455
6823
  const summary = stringOr(r.summary);
6456
6824
  const summaryTitle = stringOr(r.summary_title);
6457
6825
  const modelId = stringOr(r.model_id);
6458
6826
  const provider = stringOr(r.provider);
6459
- const version = numberOr(r.version);
6460
- const cost = numberOr(r.cost);
6461
- const durationMs = numberOr(r.duration_ms);
6827
+ const thinkingLevel = stringOr(r.thinking_level);
6828
+ const version = nonNegativeIntegerOr(r.version);
6829
+ if (typeof time !== "string" || time === "" || score === void 0 || cost === void 0 || durationMs === void 0 || !modelId || !provider || !thinkingLevel || version === void 0 || version < 1 || !Array.isArray(r.cases) || r.cases.length === 0) {
6830
+ return null;
6831
+ }
6832
+ const parsedCases = r.cases.map(toCase);
6833
+ if (parsedCases.some((item) => item === null)) return null;
6834
+ const cases = parsedCases;
6462
6835
  return {
6463
6836
  time,
6464
6837
  ...summaryTitle !== void 0 ? { summaryTitle } : {},
6465
6838
  ...summary !== void 0 ? { summary } : {},
6466
- ...modelId !== void 0 ? { modelId } : {},
6467
- ...provider !== void 0 ? { provider } : {},
6839
+ modelId,
6840
+ provider,
6841
+ thinkingLevel,
6468
6842
  score,
6469
- ...version !== void 0 ? { version } : {},
6470
- ...cost !== void 0 ? { cost } : {},
6471
- ...durationMs !== void 0 ? { durationMs } : {},
6843
+ version,
6844
+ cost,
6845
+ durationMs,
6472
6846
  cases
6473
6847
  };
6474
6848
  }
6475
6849
  var BenchmarkService = class {
6476
- constructor(root) {
6850
+ constructor(root, workspaceFiles) {
6477
6851
  this.root = root;
6852
+ this.workspaceFiles = workspaceFiles;
6478
6853
  }
6479
6854
  root;
6855
+ workspaceFiles;
6480
6856
  async list(projectId, agentId) {
6481
6857
  const dir = benchmarksDir(this.root, projectId, agentId);
6482
6858
  let items;
6483
6859
  try {
6484
- const entries = await fs12.readdir(dir, { withFileTypes: true });
6860
+ const entries = await fs13.readdir(dir, { withFileTypes: true });
6485
6861
  items = entries.map((e) => ({ name: e.name, isDir: e.isDirectory() }));
6486
6862
  } catch {
6487
6863
  return { benchmarks: [] };
6488
6864
  }
6489
6865
  const benchmarks = [];
6490
6866
  for (const item of items.filter((i) => i.isDir).sort((a, b) => a.name.localeCompare(b.name))) {
6491
- benchmarks.push(await this.readBenchmark(path11.join(dir, item.name), item.name));
6867
+ benchmarks.push(await this.readBenchmark(path12.join(dir, item.name), item.name));
6492
6868
  }
6493
6869
  return { benchmarks };
6494
6870
  }
6871
+ async listCases(projectId, agentId, benchmarkId) {
6872
+ const baseDir = benchmarksDir(this.root, projectId, agentId);
6873
+ const benchDir = path12.join(baseDir, benchmarkId);
6874
+ let entries;
6875
+ let realBaseDir;
6876
+ let realBenchDir;
6877
+ try {
6878
+ [entries, realBaseDir, realBenchDir] = await Promise.all([
6879
+ fs13.readdir(benchDir, { withFileTypes: true }),
6880
+ fs13.realpath(baseDir),
6881
+ fs13.realpath(benchDir)
6882
+ ]);
6883
+ } catch {
6884
+ return { cases: [] };
6885
+ }
6886
+ if (!isWithin(realBaseDir, realBenchDir)) return { cases: [] };
6887
+ const cases = [];
6888
+ for (const entry of entries.filter((item) => item.isDirectory() && item.name.startsWith("CASE-")).sort((a, b) => a.name.localeCompare(b.name))) {
6889
+ const fallback = { id: entry.name, title: entry.name };
6890
+ try {
6891
+ const statementDir = await this.statementRoot(projectId, agentId, benchmarkId, entry.name);
6892
+ const realReadme = await fs13.realpath(path12.join(statementDir, "README.md"));
6893
+ if (!isWithin(statementDir, realReadme)) throw new Error("README escapes Statement");
6894
+ cases.push({
6895
+ id: entry.name,
6896
+ title: await readStatementTitle(realReadme, entry.name)
6897
+ });
6898
+ } catch {
6899
+ cases.push(fallback);
6900
+ }
6901
+ }
6902
+ return { cases };
6903
+ }
6904
+ async listCaseFiles(projectId, agentId, benchmarkId, caseId, rel) {
6905
+ const statementDir = await this.statementRoot(projectId, agentId, benchmarkId, caseId);
6906
+ return this.workspaceFiles.list(statementDir, rel);
6907
+ }
6908
+ async readCaseFile(projectId, agentId, benchmarkId, caseId, rel, options) {
6909
+ const statementDir = await this.statementRoot(projectId, agentId, benchmarkId, caseId);
6910
+ return this.workspaceFiles.read(statementDir, rel, options);
6911
+ }
6912
+ async statementRoot(projectId, agentId, benchmarkId, caseId) {
6913
+ const benchDir = path12.join(benchmarksDir(this.root, projectId, agentId), benchmarkId);
6914
+ const caseDir = path12.join(benchDir, caseId);
6915
+ const statementDir = path12.join(caseDir, "statement");
6916
+ try {
6917
+ const [realBenchDir, realCaseDir, realStatementDir] = await Promise.all([
6918
+ fs13.realpath(benchDir),
6919
+ fs13.realpath(caseDir),
6920
+ fs13.realpath(statementDir)
6921
+ ]);
6922
+ if (!isWithin(realBenchDir, realCaseDir) || path12.dirname(realStatementDir) !== realCaseDir || path12.basename(realStatementDir) !== "statement") {
6923
+ throw new Error("Statement path is not canonical");
6924
+ }
6925
+ return realStatementDir;
6926
+ } catch {
6927
+ throw new HttpError(404, "not_found", "Public Case Statement does not exist.");
6928
+ }
6929
+ }
6495
6930
  async readBenchmark(benchDir, id) {
6496
6931
  let title = id;
6497
6932
  let description;
6498
6933
  let runs;
6499
6934
  try {
6500
6935
  const config2 = asRecord2(
6501
- parseToml3(await fs12.readFile(path11.join(benchDir, "benchmark_config.toml"), "utf8"))
6936
+ parseToml3(await fs13.readFile(path12.join(benchDir, "benchmark_config.toml"), "utf8"))
6502
6937
  );
6503
6938
  if (typeof config2.title === "string" && config2.title !== "") title = config2.title;
6504
6939
  if (typeof config2.description === "string" && config2.description !== "") {
@@ -6513,7 +6948,7 @@ var BenchmarkService = class {
6513
6948
  let evaluations = [];
6514
6949
  try {
6515
6950
  const scoreboard = asRecord2(
6516
- parseYaml2(await fs12.readFile(path11.join(benchDir, "scoreboard.yaml"), "utf8"))
6951
+ parseYaml2(await fs13.readFile(path12.join(benchDir, "scoreboard.yaml"), "utf8"))
6517
6952
  );
6518
6953
  if (Array.isArray(scoreboard.evaluations)) {
6519
6954
  evaluations = scoreboard.evaluations.map(toEvaluation).filter((e) => e !== null);
@@ -6522,8 +6957,8 @@ var BenchmarkService = class {
6522
6957
  }
6523
6958
  let caseCount = 0;
6524
6959
  try {
6525
- const entries = await fs12.readdir(benchDir, { withFileTypes: true });
6526
- caseCount = entries.filter((e) => e.isDirectory()).length;
6960
+ const entries = await fs13.readdir(benchDir, { withFileTypes: true });
6961
+ caseCount = entries.filter((e) => e.isDirectory() && e.name.startsWith("CASE-")).length;
6527
6962
  } catch {
6528
6963
  }
6529
6964
  return {
@@ -6538,9 +6973,9 @@ var BenchmarkService = class {
6538
6973
  };
6539
6974
 
6540
6975
  // src/services/snapshot-service.ts
6541
- import { randomBytes as randomBytes5 } from "crypto";
6542
- import fs13 from "fs/promises";
6543
- import path12 from "path";
6976
+ import { randomBytes as randomBytes6 } from "crypto";
6977
+ import fs14 from "fs/promises";
6978
+ import path13 from "path";
6544
6979
  import * as tar from "tar";
6545
6980
  import { parse as parseYaml3 } from "yaml";
6546
6981
  import {
@@ -6553,7 +6988,7 @@ import {
6553
6988
  } from "@prismshadow/penguin-core";
6554
6989
  var VAULT_BASENAME = ".vault.toml";
6555
6990
  function isVaultEntry(entryPath) {
6556
- return path12.posix.basename(entryPath.replaceAll("\\", "/")) === VAULT_BASENAME;
6991
+ return path13.posix.basename(entryPath.replaceAll("\\", "/")) === VAULT_BASENAME;
6557
6992
  }
6558
6993
  var SnapshotService = class {
6559
6994
  constructor(root) {
@@ -6564,7 +6999,7 @@ var SnapshotService = class {
6564
6999
  async currentVersion(projectId, agentId) {
6565
7000
  let raw;
6566
7001
  try {
6567
- raw = await fs13.readFile(systemConfigPath3(this.root, projectId, agentId), "utf8");
7002
+ raw = await fs14.readFile(systemConfigPath3(this.root, projectId, agentId), "utf8");
6568
7003
  } catch {
6569
7004
  throw new HttpError(404, "agent_not_found", "Agent does not exist.");
6570
7005
  }
@@ -6577,14 +7012,14 @@ var SnapshotService = class {
6577
7012
  async ensureSnapshot(projectId, agentId) {
6578
7013
  const version = await this.currentVersion(projectId, agentId);
6579
7014
  const dir = snapshotsDir(this.root, projectId, agentId);
6580
- const file = path12.join(dir, `v${version}.tar.gz`);
7015
+ const file = path13.join(dir, `v${version}.tar.gz`);
6581
7016
  try {
6582
- await fs13.access(file);
7017
+ await fs14.access(file);
6583
7018
  return { version, file };
6584
7019
  } catch {
6585
7020
  }
6586
- await fs13.mkdir(dir, { recursive: true });
6587
- const tmp = `${file}.tmp-${randomBytes5(4).toString("hex")}`;
7021
+ await fs14.mkdir(dir, { recursive: true });
7022
+ const tmp = `${file}.tmp-${randomBytes6(4).toString("hex")}`;
6588
7023
  await tar.create(
6589
7024
  {
6590
7025
  gzip: true,
@@ -6595,7 +7030,7 @@ var SnapshotService = class {
6595
7030
  },
6596
7031
  ["agent_state"]
6597
7032
  );
6598
- await fs13.rename(tmp, file);
7033
+ await fs14.rename(tmp, file);
6599
7034
  return { version, file };
6600
7035
  }
6601
7036
  /** Export: automatically packs a snapshot first if none exists, returns the info needed for download. */
@@ -6612,13 +7047,13 @@ var SnapshotService = class {
6612
7047
  async importArchive(projectId, agentId, archive, confirm) {
6613
7048
  const current = await this.currentVersion(projectId, agentId);
6614
7049
  const base = agentDir2(this.root, projectId, agentId);
6615
- const staging = path12.join(base, `.import-${randomBytes5(6).toString("hex")}`);
6616
- await fs13.mkdir(staging, { recursive: true });
7050
+ const staging = path13.join(base, `.import-${randomBytes6(6).toString("hex")}`);
7051
+ await fs14.mkdir(staging, { recursive: true });
6617
7052
  try {
6618
- const archiveFile = path12.join(staging, "archive.tar.gz");
6619
- await fs13.writeFile(archiveFile, archive);
6620
- const extractDir = path12.join(staging, "extracted");
6621
- await fs13.mkdir(extractDir, { recursive: true });
7053
+ const archiveFile = path13.join(staging, "archive.tar.gz");
7054
+ await fs14.writeFile(archiveFile, archive);
7055
+ const extractDir = path13.join(staging, "extracted");
7056
+ await fs14.mkdir(extractDir, { recursive: true });
6622
7057
  try {
6623
7058
  await tar.extract({
6624
7059
  file: archiveFile,
@@ -6628,10 +7063,10 @@ var SnapshotService = class {
6628
7063
  } catch {
6629
7064
  throw badRequest("Import failed: not a valid tar.gz snapshot package.");
6630
7065
  }
6631
- const configPath = path12.join(extractDir, "agent_state", "system_config.yaml");
7066
+ const configPath = path13.join(extractDir, "agent_state", "system_config.yaml");
6632
7067
  let parsed;
6633
7068
  try {
6634
- parsed = parseYaml3(await fs13.readFile(configPath, "utf8"));
7069
+ parsed = parseYaml3(await fs14.readFile(configPath, "utf8"));
6635
7070
  } catch {
6636
7071
  throw badRequest("Import failed: the package is missing agent_state/system_config.yaml.");
6637
7072
  }
@@ -6654,37 +7089,37 @@ var SnapshotService = class {
6654
7089
  }
6655
7090
  await this.ensureSnapshot(projectId, agentId);
6656
7091
  const stateDir = agentStateDir2(this.root, projectId, agentId);
6657
- const incomingState = path12.join(extractDir, "agent_state");
7092
+ const incomingState = path13.join(extractDir, "agent_state");
6658
7093
  let vault = null;
6659
7094
  try {
6660
- vault = await fs13.readFile(agentVaultPath(this.root, projectId, agentId));
7095
+ vault = await fs14.readFile(agentVaultPath(this.root, projectId, agentId));
6661
7096
  } catch {
6662
7097
  }
6663
7098
  if (vault !== null) {
6664
- await fs13.writeFile(path12.join(incomingState, VAULT_BASENAME), vault, { mode: 384 });
7099
+ await fs14.writeFile(path13.join(incomingState, VAULT_BASENAME), vault, { mode: 384 });
6665
7100
  }
6666
- const trash = path12.join(staging, "replaced-agent_state");
6667
- await fs13.rename(stateDir, trash);
7101
+ const trash = path13.join(staging, "replaced-agent_state");
7102
+ await fs14.rename(stateDir, trash);
6668
7103
  try {
6669
- await fs13.rename(incomingState, stateDir);
7104
+ await fs14.rename(incomingState, stateDir);
6670
7105
  } catch (err) {
6671
- await fs13.rename(trash, stateDir);
7106
+ await fs14.rename(trash, stateDir);
6672
7107
  throw err;
6673
7108
  }
6674
7109
  return { version: incoming };
6675
7110
  } finally {
6676
- await fs13.rm(staging, { recursive: true, force: true });
7111
+ await fs14.rm(staging, { recursive: true, force: true });
6677
7112
  }
6678
7113
  }
6679
7114
  };
6680
7115
 
6681
7116
  // src/services/project-service.ts
6682
- import fs14 from "fs/promises";
7117
+ import fs15 from "fs/promises";
6683
7118
  import { DEFAULT_PROJECT_ID, projectDir, provisionProjectAgents } from "@prismshadow/penguin-core";
6684
7119
  var ABORT_SETTLE_TIMEOUT_MS = 5e3;
6685
- async function dirExists(path17) {
7120
+ async function dirExists(path18) {
6686
7121
  try {
6687
- const stat = await fs14.stat(path17);
7122
+ const stat = await fs15.stat(path18);
6688
7123
  return stat.isDirectory();
6689
7124
  } catch {
6690
7125
  return false;
@@ -6809,12 +7244,12 @@ var ProjectService = class {
6809
7244
  throw err;
6810
7245
  }
6811
7246
  try {
6812
- await fs14.mkdir(projectDir(this.deps.root, projectId), { recursive: true });
7247
+ await fs15.mkdir(projectDir(this.deps.root, projectId), { recursive: true });
6813
7248
  await this.deps.projectConfig.writeInitialConfig(projectId, displayName);
6814
7249
  await this.provisionBuiltinAgents(projectId);
6815
7250
  } catch (err) {
6816
7251
  this.deps.projects.delete(projectId);
6817
- await fs14.rm(projectDir(this.deps.root, projectId), { recursive: true, force: true }).catch(() => {
7252
+ await fs15.rm(projectDir(this.deps.root, projectId), { recursive: true, force: true }).catch(() => {
6818
7253
  });
6819
7254
  throw err;
6820
7255
  }
@@ -6847,6 +7282,23 @@ var ProjectService = class {
6847
7282
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
6848
7283
  });
6849
7284
  }
7285
+ /**
7286
+ * Rename a Project's display name (owner): the id stays immutable — it names the directory,
7287
+ * the Workspace paths and every stored reference — so only the label in project_config.toml
7288
+ * changes. Returns the refreshed summary so the caller can swap it into its list without a
7289
+ * reload.
7290
+ */
7291
+ async renameProject(userId, projectId, name) {
7292
+ const row = this.requireProjectOwner(userId, projectId);
7293
+ await this.deps.projectConfig.setName(projectId, name);
7294
+ return {
7295
+ projectId,
7296
+ name,
7297
+ role: "owner",
7298
+ ownerUserId: row.ownerUserId,
7299
+ createdAt: row.createdAt
7300
+ };
7301
+ }
6850
7302
  /**
6851
7303
  * Delete a Project (owner): default_project is refused; deleting the user's
6852
7304
  * **last accessible Project** is refused too (deleting it would leave the list
@@ -6895,7 +7347,7 @@ var ProjectService = class {
6895
7347
  this.deps.errors.deleteByProject(projectId);
6896
7348
  this.deps.schedules.deleteByProject(projectId);
6897
7349
  this.deps.goals.deleteByProject(projectId);
6898
- await fs14.rm(projectDir(this.deps.root, projectId), { recursive: true, force: true });
7350
+ await fs15.rm(projectDir(this.deps.root, projectId), { recursive: true, force: true });
6899
7351
  }
6900
7352
  // —— Member authorization ——
6901
7353
  /** Member list: owner (role=owner) + members. */
@@ -6966,7 +7418,7 @@ var ProjectService = class {
6966
7418
  };
6967
7419
 
6968
7420
  // src/services/session-service.ts
6969
- import path13 from "path";
7421
+ import path14 from "path";
6970
7422
  import { open, readdir as readdir2 } from "fs/promises";
6971
7423
  import {
6972
7424
  createAgent as createAgent2,
@@ -7148,7 +7600,7 @@ var SessionService = class {
7148
7600
  };
7149
7601
  const dir = tracesDir2(this.deps.root, projectId, agentId);
7150
7602
  for (const dateDir of await listDirsSafe(dir)) {
7151
- for (const file of await listFilesSafe(path13.join(dir, dateDir))) {
7603
+ for (const file of await listFilesSafe(path14.join(dir, dateDir))) {
7152
7604
  const match = TRACE_FILE_RE.exec(file);
7153
7605
  if (match) mark(dateDir, match[1]);
7154
7606
  }
@@ -7264,14 +7716,14 @@ var SessionService = class {
7264
7716
  const dir = tracesDir2(this.deps.root, projectId, agentId);
7265
7717
  const out = /* @__PURE__ */ new Map();
7266
7718
  for (const dateDir of await listDirsSafe(dir)) {
7267
- for (const file of await listFilesSafe(path13.join(dir, dateDir))) {
7719
+ for (const file of await listFilesSafe(path14.join(dir, dateDir))) {
7268
7720
  const match = TRACE_FILE_RE.exec(file);
7269
7721
  if (!match) continue;
7270
7722
  const sessionId = match[1];
7271
7723
  const index = Number(match[2]);
7272
7724
  const cur = out.get(sessionId);
7273
7725
  if (!cur || index < cur.index) {
7274
- out.set(sessionId, { path: path13.join(dir, dateDir, file), index });
7726
+ out.set(sessionId, { path: path14.join(dir, dateDir, file), index });
7275
7727
  }
7276
7728
  }
7277
7729
  }
@@ -7334,8 +7786,8 @@ async function listFilesSafe(dir) {
7334
7786
  }
7335
7787
 
7336
7788
  // src/services/trace-service.ts
7337
- import fs15 from "fs/promises";
7338
- import path14 from "path";
7789
+ import fs16 from "fs/promises";
7790
+ import path15 from "path";
7339
7791
  import {
7340
7792
  agentsDir as agentsDir3,
7341
7793
  isSessionMeta as isSessionMeta5,
@@ -7357,7 +7809,7 @@ function subagentPointer(msg) {
7357
7809
  }
7358
7810
  async function listDirs(dir) {
7359
7811
  try {
7360
- const entries = await fs15.readdir(dir, { withFileTypes: true });
7812
+ const entries = await fs16.readdir(dir, { withFileTypes: true });
7361
7813
  return entries.filter((e) => e.isDirectory()).map((e) => e.name);
7362
7814
  } catch {
7363
7815
  return [];
@@ -7365,7 +7817,7 @@ async function listDirs(dir) {
7365
7817
  }
7366
7818
  async function listFiles(dir) {
7367
7819
  try {
7368
- const entries = await fs15.readdir(dir, { withFileTypes: true });
7820
+ const entries = await fs16.readdir(dir, { withFileTypes: true });
7369
7821
  return entries.filter((e) => e.isFile()).map((e) => e.name);
7370
7822
  } catch {
7371
7823
  return [];
@@ -7381,10 +7833,10 @@ var TraceService = class {
7381
7833
  const dir = tracesDir3(this.root, projectId, agentId);
7382
7834
  const out = [];
7383
7835
  for (const dateDir of await listDirs(dir)) {
7384
- for (const file of await listFiles(path14.join(dir, dateDir))) {
7836
+ for (const file of await listFiles(path15.join(dir, dateDir))) {
7385
7837
  const match = TRACE_FILE_RE2.exec(file);
7386
7838
  if (!match || match[1] !== sessionId) continue;
7387
- out.push({ path: path14.join(dir, dateDir, file), date: dateDir, index: Number(match[2]) });
7839
+ out.push({ path: path15.join(dir, dateDir, file), date: dateDir, index: Number(match[2]) });
7388
7840
  }
7389
7841
  }
7390
7842
  return out.sort((a, b) => a.index - b.index);
@@ -7393,7 +7845,7 @@ var TraceService = class {
7393
7845
  async deleteSessionTraces(projectId, agentId, sessionId) {
7394
7846
  const files = await this.locateAll(projectId, agentId, sessionId);
7395
7847
  for (const file of files) {
7396
- await fs15.rm(file.path, { force: true });
7848
+ await fs16.rm(file.path, { force: true });
7397
7849
  }
7398
7850
  }
7399
7851
  /**
@@ -7431,7 +7883,7 @@ var TraceService = class {
7431
7883
  for (const agentId of await listDirs(agentsDir3(this.root, projectId))) {
7432
7884
  const dir = tracesDir3(this.root, projectId, agentId);
7433
7885
  for (const dateDir of await listDirs(dir)) {
7434
- for (const file of await listFiles(path14.join(dir, dateDir))) {
7886
+ for (const file of await listFiles(path15.join(dir, dateDir))) {
7435
7887
  const match = TRACE_FILE_RE2.exec(file);
7436
7888
  if (match && !index.has(match[1])) index.set(match[1], agentId);
7437
7889
  }
@@ -7474,7 +7926,7 @@ var TraceService = class {
7474
7926
  const files = await this.locateAll(projectId, agentId, sessionId);
7475
7927
  const out = [];
7476
7928
  for (const file of files) {
7477
- const stat = await fs15.stat(file.path);
7929
+ const stat = await fs16.stat(file.path);
7478
7930
  out.push({
7479
7931
  index: file.index,
7480
7932
  date: file.date,
@@ -7510,6 +7962,7 @@ var TraceService = class {
7510
7962
  let prevSerialTs = null;
7511
7963
  let taskIndex = -1;
7512
7964
  let continuation = false;
7965
+ let steeringImages = false;
7513
7966
  let sawToolCallThisRequest = false;
7514
7967
  let compactionActive = false;
7515
7968
  const taskStats = /* @__PURE__ */ new Map();
@@ -7537,7 +7990,10 @@ var TraceService = class {
7537
7990
  const hasOrigin = msg.origin !== void 0 && msg.origin.length > 0;
7538
7991
  const isSteeringText = !hasOrigin && msg.type === "model_msg" && p.type === "text" && p.role === "user" && typeof p.text === "string" && parseUserSteeringText(p.text) !== null;
7539
7992
  if (isSteeringText) continuation = true;
7540
- const startsUserTurn = !hasOrigin && !compactionActive && !isSteeringText && msg.type === "model_msg" && (p.type === "text" && p.role === "user" || p.type === "image_url");
7993
+ const isImage = !hasOrigin && msg.type === "model_msg" && p.type === "image_url";
7994
+ if (!hasOrigin && !isSteeringText && !(isImage && steeringImages)) steeringImages = false;
7995
+ if (isSteeringText) steeringImages = true;
7996
+ const startsUserTurn = !hasOrigin && !compactionActive && !isSteeringText && !steeringImages && msg.type === "model_msg" && (p.type === "text" && p.role === "user" || p.type === "image_url");
7541
7997
  const startsCompactionTurn = !hasOrigin && msg.type === "event_msg" && p.type === "compaction_begin";
7542
7998
  if (startsUserTurn || startsCompactionTurn) {
7543
7999
  if (pendingFrom === null) pendingFrom = mi;
@@ -7582,7 +8038,7 @@ var TraceService = class {
7582
8038
  }
7583
8039
  } else if (p.type === "request_end") {
7584
8040
  const status = typeof p.status === "string" ? p.status : void 0;
7585
- const retryable = status === "timeout" || status === "malformed";
8041
+ const retryable = status === "timeout" || status === "malformed" || status === "failed" && !compactionActive;
7586
8042
  if (!hasOrigin) {
7587
8043
  prevSerialTs = null;
7588
8044
  continuation = sawToolCallThisRequest || retryable;
@@ -7733,11 +8189,11 @@ var TraceService = class {
7733
8189
  const out = { dates: [] };
7734
8190
  for (const date of dates) {
7735
8191
  const bySession = /* @__PURE__ */ new Map();
7736
- for (const file of await listFiles(path14.join(dir, date))) {
8192
+ for (const file of await listFiles(path15.join(dir, date))) {
7737
8193
  const match = TRACE_FILE_RE2.exec(file);
7738
8194
  if (!match) continue;
7739
8195
  const sessionId = match[1];
7740
- const stat = await fs15.stat(path14.join(dir, date, file));
8196
+ const stat = await fs16.stat(path15.join(dir, date, file));
7741
8197
  const files = bySession.get(sessionId) ?? [];
7742
8198
  files.push({ index: Number(match[2]), sizeBytes: stat.size });
7743
8199
  bySession.set(sessionId, files);
@@ -7773,7 +8229,7 @@ var TraceService = class {
7773
8229
  /** Raw bytes of the Trace file at the given index (export/download: the file is served verbatim). */
7774
8230
  async readFileRaw(projectId, agentId, sessionId, index) {
7775
8231
  const file = await this.locateByIndex(projectId, agentId, sessionId, index);
7776
- return fs15.readFile(file.path);
8232
+ return fs16.readFile(file.path);
7777
8233
  }
7778
8234
  /**
7779
8235
  * Imports an uploaded Trace file (raw JSONL content). Validates the content itself —
@@ -7812,11 +8268,11 @@ var TraceService = class {
7812
8268
  const ts = Date.parse(first.timestamp);
7813
8269
  const date = formatLocalDate(Number.isNaN(ts) ? /* @__PURE__ */ new Date() : new Date(ts));
7814
8270
  const index = 1;
7815
- const dir = path14.join(tracesDir3(this.root, projectId, agentId), date);
7816
- await fs15.mkdir(dir, { recursive: true });
7817
- const file = path14.join(dir, `${sessionId}_${String(index).padStart(3, "0")}.jsonl`);
8271
+ const dir = path15.join(tracesDir3(this.root, projectId, agentId), date);
8272
+ await fs16.mkdir(dir, { recursive: true });
8273
+ const file = path15.join(dir, `${sessionId}_${String(index).padStart(3, "0")}.jsonl`);
7818
8274
  try {
7819
- await fs15.writeFile(file, content.replace(/\n+$/, "") + "\n", {
8275
+ await fs16.writeFile(file, content.replace(/\n+$/, "") + "\n", {
7820
8276
  encoding: "utf8",
7821
8277
  flag: "wx"
7822
8278
  });
@@ -8015,6 +8471,33 @@ var UsageService = class {
8015
8471
  models: this.usage.distinctModels(projectId)
8016
8472
  };
8017
8473
  }
8474
+ /**
8475
+ * One page of the error detail table, newest first. The dashboard's own response already
8476
+ * carries the first page (`errors.recent`); this serves the "show me earlier ones" paging,
8477
+ * where refetching the whole aggregate to move one page would be wasteful. `total` is the
8478
+ * filtered row count, so the caller knows when it has reached the end.
8479
+ *
8480
+ * Takes the same filter the dashboard applies — date + agent, and admin-only visibility of
8481
+ * unattributed errors — so a page never widens what the summary above it counted.
8482
+ *
8483
+ * Paging is by offset into a newest-first table that grows at its head, so errors recorded
8484
+ * between two page requests shift every row down and a page can re-show rows already seen.
8485
+ * Accepted deliberately: this is a diagnostic table read at a moment in time, not a feed to
8486
+ * be walked exhaustively, and keying off the newest row's id would cost a cursor the caller
8487
+ * has no other use for.
8488
+ */
8489
+ queryErrors(projectId, q) {
8490
+ const f = {
8491
+ ...q.agentId !== void 0 ? { agentId: q.agentId } : {},
8492
+ ...q.from !== void 0 ? { from: q.from } : {},
8493
+ ...q.to !== void 0 ? { to: q.to } : {},
8494
+ ...q.includeGlobalErrors === true ? { includeGlobal: true } : {}
8495
+ };
8496
+ return {
8497
+ items: this.errors.recent(projectId, f, q.limit, q.offset),
8498
+ total: this.errors.summary(projectId, f).total
8499
+ };
8500
+ }
8018
8501
  /** Error statistics: summary info (total / unexpected / most common error code) + the last N entries, all filtered by the selected range. */
8019
8502
  foldErrors(projectId, f) {
8020
8503
  const { total, unexpected } = this.errors.summary(projectId, f);
@@ -8167,7 +8650,7 @@ function buildAppDeps(config2, overrides = {}) {
8167
8650
  const traceService = new TraceService(config2.root);
8168
8651
  const workspaceFiles = new WorkspaceFilesService();
8169
8652
  const previewTokens = createPreviewTokenSigner();
8170
- const benchmarks = new BenchmarkService(config2.root);
8653
+ const benchmarks = new BenchmarkService(config2.root, workspaceFiles);
8171
8654
  const snapshots = new SnapshotService(config2.root);
8172
8655
  const usageService = new UsageService(
8173
8656
  usageRepo,
@@ -8308,13 +8791,17 @@ function createApp(deps2) {
8308
8791
  await next();
8309
8792
  });
8310
8793
  }
8311
- app2.use("/api/*", async (c, next) => {
8312
- const contentLength = Number(c.req.header("content-length") ?? 0);
8313
- if (contentLength > MAX_BODY_BYTES) {
8314
- throw new HttpError(413, "payload_too_large", "Request body exceeds the 20MB limit.");
8315
- }
8316
- await next();
8317
- });
8794
+ app2.use(
8795
+ "/api/*",
8796
+ bodyLimit({
8797
+ maxSize: MAX_BODY_BYTES,
8798
+ // Its default is a bare text/plain 413; throw the App's own error instead so the response
8799
+ // stays the documented `payload_too_large` body that every client already handles.
8800
+ onError: () => {
8801
+ throw new HttpError(413, "payload_too_large", "Request body exceeds the 20MB limit.");
8802
+ }
8803
+ })
8804
+ );
8318
8805
  app2.use("/api/*", jsonOnlyWrites);
8319
8806
  app2.route("/api/auth", authRoutes(deps2));
8320
8807
  const auth = authMiddleware(deps2.authService);
@@ -8340,7 +8827,7 @@ function createApp(deps2) {
8340
8827
  app2.route("/api/projects/:projectId/usage", usageRoutes(deps2));
8341
8828
  app2.route("/api/sessions", sessionsRoutes(deps2));
8342
8829
  app2.route("/preview", previewRoutes(deps2));
8343
- if (fs16.existsSync(deps2.config.webDist)) {
8830
+ if (fs17.existsSync(deps2.config.webDist)) {
8344
8831
  registerStaticRoutes(app2, deps2.config.webDist);
8345
8832
  }
8346
8833
  return app2;
@@ -8376,16 +8863,16 @@ function registerStaticRoutes(app2, webDist) {
8376
8863
  return c.json(errorBody("not_found", "Endpoint does not exist."), 404);
8377
8864
  }
8378
8865
  const rel = reqPath.replace(/^\/+/, "");
8379
- const resolved = path15.resolve(webDist, rel === "" ? "index.html" : rel);
8380
- const base = path15.resolve(webDist);
8381
- const target = resolved === base || resolved.startsWith(base + path15.sep) ? resolved : path15.join(base, "index.html");
8866
+ const resolved = path16.resolve(webDist, rel === "" ? "index.html" : rel);
8867
+ const base = path16.resolve(webDist);
8868
+ const target = resolved === base || resolved.startsWith(base + path16.sep) ? resolved : path16.join(base, "index.html");
8382
8869
  let file = target;
8383
8870
  try {
8384
8871
  const stat = await fsp.stat(file);
8385
- if (stat.isDirectory()) file = path15.join(file, "index.html");
8872
+ if (stat.isDirectory()) file = path16.join(file, "index.html");
8386
8873
  await fsp.access(file);
8387
8874
  } catch {
8388
- file = path15.join(base, "index.html");
8875
+ file = path16.join(base, "index.html");
8389
8876
  }
8390
8877
  let content;
8391
8878
  try {
@@ -8393,7 +8880,7 @@ function registerStaticRoutes(app2, webDist) {
8393
8880
  } catch {
8394
8881
  return c.json(errorBody("not_found", "Resource does not exist."), 404);
8395
8882
  }
8396
- const type = CONTENT_TYPES2[path15.extname(file).toLowerCase()] ?? "application/octet-stream";
8883
+ const type = CONTENT_TYPES2[path16.extname(file).toLowerCase()] ?? "application/octet-stream";
8397
8884
  return new Response(new Uint8Array(content), {
8398
8885
  status: 200,
8399
8886
  headers: { "Content-Type": type }
@@ -8402,16 +8889,16 @@ function registerStaticRoutes(app2, webDist) {
8402
8889
  }
8403
8890
 
8404
8891
  // src/config.ts
8405
- import fs17 from "fs";
8406
- import path16 from "path";
8892
+ import fs18 from "fs";
8893
+ import path17 from "path";
8407
8894
  import { fileURLToPath } from "url";
8408
8895
  import { DEFAULT_SERVER_PORT, resolveRoot } from "@prismshadow/penguin-core";
8409
8896
  var DAY_MS = 24 * 60 * 60 * 1e3;
8410
8897
  function defaultWebDist() {
8411
- const here = path16.dirname(fileURLToPath(import.meta.url));
8412
- const bundled = path16.resolve(here, "..", "web-dist");
8413
- if (fs17.existsSync(bundled)) return bundled;
8414
- return path16.resolve(here, "..", "..", "web", "dist");
8898
+ const here = path17.dirname(fileURLToPath(import.meta.url));
8899
+ const bundled = path17.resolve(here, "..", "web-dist");
8900
+ if (fs18.existsSync(bundled)) return bundled;
8901
+ return path17.resolve(here, "..", "..", "web", "dist");
8415
8902
  }
8416
8903
  function normalizePreviewOrigin(raw) {
8417
8904
  if (!raw || raw.trim() === "") return null;
@@ -8436,7 +8923,7 @@ function resolveServerConfig(env = process.env) {
8436
8923
  root,
8437
8924
  host: env.HOST ?? "127.0.0.1",
8438
8925
  port,
8439
- dbPath: env.PENGUIN_WEB_DB ?? path16.join(root, "web.db"),
8926
+ dbPath: env.PENGUIN_WEB_DB ?? path17.join(root, "web.db"),
8440
8927
  webDist: env.PENGUIN_WEB_DIST ?? defaultWebDist(),
8441
8928
  previewOrigin: normalizePreviewOrigin(env.PENGUIN_PREVIEW_ORIGIN),
8442
8929
  authSessionTtlMs: 7 * DAY_MS,