@prismshadow/penguin-server 0.1.4 → 0.2.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.
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";
@@ -54,8 +55,11 @@ CREATE TABLE IF NOT EXISTS sessions (
54
55
  approval_mode TEXT NOT NULL DEFAULT 'allow-all', -- allow-all|deny-all|read-only|always-ask
55
56
  title TEXT, -- auto-generated by the model after the first exchange; NULL=not yet (frontend shows "New chat")
56
57
  archived_at TEXT, -- archive time; NULL=not archived (shown by default; archived ones move under "Archived")
58
+ client TEXT, -- creating client: 'web' (created via the Web App) | 'cli' (adopted from a CLI Trace); NULL = legacy row, treated as web
59
+ has_trace INTEGER NOT NULL DEFAULT 0, -- cache: a Trace record exists (set at task start / adoption / subagent registration; lazily backfilled by list hydration)
57
60
  created_at TEXT NOT NULL
58
- ); -- session origin is NOT stored: session_meta in the Trace is the single source of truth (see runtime/session-sources.ts)
61
+ ); -- the schedule/subagent SOURCE is NOT stored: session_meta in the Trace is the single source of truth (see runtime/session-sources.ts); "client" is a different, DB-only axis (who created the row)
62
+ CREATE INDEX IF NOT EXISTS idx_sessions_agent_created ON sessions(project_id, agent_id, created_at DESC);
59
63
  CREATE TABLE IF NOT EXISTS usage_records (
60
64
  id INTEGER PRIMARY KEY AUTOINCREMENT,
61
65
  ts TEXT NOT NULL,
@@ -132,8 +136,15 @@ function openDatabase(dbPath) {
132
136
  db.exec("PRAGMA journal_mode = WAL;");
133
137
  db.exec("PRAGMA foreign_keys = ON;");
134
138
  db.exec(SCHEMA_SQL);
139
+ ensureColumn(db, "sessions", "client", "TEXT");
140
+ ensureColumn(db, "sessions", "has_trace", "INTEGER NOT NULL DEFAULT 0");
135
141
  return db;
136
142
  }
143
+ function ensureColumn(db, table, column, ddl) {
144
+ const cols = db.prepare(`PRAGMA table_info(${table})`).all();
145
+ if (cols.some((c) => c.name === column)) return;
146
+ db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${ddl}`);
147
+ }
137
148
 
138
149
  // src/db/repos/agents.ts
139
150
  var AgentsRepo = class {
@@ -299,13 +310,13 @@ var ErrorsRepo = class {
299
310
  };
300
311
  }
301
312
  /** The most recent `limit` entries (reverse chronological order). */
302
- recent(projectId, f = {}, limit = 20) {
313
+ recent(projectId, f = {}, limit = 20, offset = 0) {
303
314
  const { where, params } = this.conds(projectId, f);
304
315
  const rows = this.db.prepare(
305
316
  `SELECT ts, source, code, kind, message
306
317
  FROM error_records WHERE ${where}
307
- ORDER BY id DESC LIMIT :limit`
308
- ).all({ ...params, limit });
318
+ ORDER BY id DESC LIMIT :limit OFFSET :offset`
319
+ ).all({ ...params, limit, offset });
309
320
  return rows.map((r) => ({
310
321
  ts: r.ts,
311
322
  source: r.source,
@@ -614,6 +625,8 @@ function mapRow4(r) {
614
625
  approvalMode: r.approval_mode,
615
626
  title: r.title ?? null,
616
627
  archivedAt: r.archived_at ?? null,
628
+ client: r.client ?? null,
629
+ hasTrace: r.has_trace === 1,
617
630
  createdAt: r.created_at
618
631
  };
619
632
  }
@@ -624,8 +637,8 @@ var SessionsRepo = class {
624
637
  db;
625
638
  insert(row) {
626
639
  this.db.prepare(
627
- `INSERT INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, created_at)
628
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
640
+ `INSERT INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, client, has_trace, created_at)
641
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
629
642
  ).run(
630
643
  row.sessionId,
631
644
  row.projectId,
@@ -635,14 +648,16 @@ var SessionsRepo = class {
635
648
  row.workspace,
636
649
  row.approvalMode,
637
650
  row.title,
651
+ row.client ?? null,
652
+ row.hasTrace ? 1 : 0,
638
653
  row.createdAt
639
654
  );
640
655
  }
641
656
  /** Idempotent insert: used when Trace directory discovery backfills a row (concurrent listing discovering the same Session no longer triggers a UNIQUE violation). */
642
657
  insertOrIgnore(row) {
643
658
  this.db.prepare(
644
- `INSERT OR IGNORE INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, created_at)
645
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
659
+ `INSERT OR IGNORE INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, client, has_trace, created_at)
660
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
646
661
  ).run(
647
662
  row.sessionId,
648
663
  row.projectId,
@@ -652,15 +667,30 @@ var SessionsRepo = class {
652
667
  row.workspace,
653
668
  row.approvalMode,
654
669
  row.title,
670
+ row.client ?? null,
671
+ row.hasTrace ? 1 : 0,
655
672
  row.createdAt
656
673
  );
657
674
  }
675
+ /** Flip the has_trace cache once a Trace record exists (task start / discovery hydration); idempotent. */
676
+ markHasTrace(sessionId) {
677
+ this.db.prepare("UPDATE sessions SET has_trace = 1 WHERE session_id = ?").run(sessionId);
678
+ }
658
679
  findById(sessionId) {
659
680
  const r = this.db.prepare("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
660
681
  return r ? mapRow4(r) : null;
661
682
  }
662
- listByAgent(projectId, agentId) {
663
- const rows = this.db.prepare("SELECT * FROM sessions WHERE project_id = ? AND agent_id = ?").all(projectId, agentId);
683
+ /**
684
+ * An Agent's rows, newest first (the list order the sidebar shows; served by
685
+ * idx_sessions_agent_created). `webOnly` keeps only web-created rows — NULL counts as
686
+ * web (legacy rows predate the column and the user chose to grandfather them as visible).
687
+ */
688
+ listByAgent(projectId, agentId, opts = {}) {
689
+ const filter = opts.webOnly ? " AND (client IS NULL OR client = 'web')" : "";
690
+ const rows = this.db.prepare(
691
+ `SELECT * FROM sessions WHERE project_id = ? AND agent_id = ?${filter}
692
+ ORDER BY created_at DESC, session_id DESC`
693
+ ).all(projectId, agentId);
664
694
  return rows.map(mapRow4);
665
695
  }
666
696
  listByProject(projectId) {
@@ -1563,6 +1593,14 @@ function projectsRoutes(deps2) {
1563
1593
  const project = await deps2.projectService.createProject(c.var.user, projectId, name);
1564
1594
  return c.json({ project }, 201);
1565
1595
  });
1596
+ app2.patch("/:projectId", async (c) => {
1597
+ const projectId = requireValidId(c, "projectId");
1598
+ const body = await readJson(c);
1599
+ const name = requireString(body, "name", { maxLen: 100, label: "name" }).trim();
1600
+ if (name === "") throw badRequest("name must be at least 1 characters.");
1601
+ const project = await deps2.projectService.renameProject(c.var.user.userId, projectId, name);
1602
+ return c.json({ project });
1603
+ });
1566
1604
  app2.delete("/:projectId", async (c) => {
1567
1605
  await deps2.projectService.deleteProject(c.var.user.userId, requireValidId(c, "projectId"));
1568
1606
  return c.body(null, 204);
@@ -2190,6 +2228,57 @@ async function readItem(deps2, projectId, agentId, name) {
2190
2228
 
2191
2229
  // src/http/routes/benchmarks.ts
2192
2230
  import { Hono as Hono10 } from "hono";
2231
+ var TEXT_PREVIEW_BYTES = 256 * 1024;
2232
+ function listCaseFiles(deps2, material) {
2233
+ return async (c) => {
2234
+ const projectId = requireValidId(c, "projectId");
2235
+ const agentId = requireValidId(c, "agentId");
2236
+ const benchmarkId = requireValidId(c, "benchmarkId");
2237
+ const caseId = requireValidId(c, "caseId");
2238
+ deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
2239
+ await deps2.agentConfigService.requireExists(projectId, agentId);
2240
+ return c.json(
2241
+ await deps2.benchmarks.listCaseFiles(
2242
+ projectId,
2243
+ agentId,
2244
+ benchmarkId,
2245
+ caseId,
2246
+ c.req.query("path") ?? "",
2247
+ material
2248
+ )
2249
+ );
2250
+ };
2251
+ }
2252
+ function readCaseFile(deps2, material) {
2253
+ return async (c) => {
2254
+ const projectId = requireValidId(c, "projectId");
2255
+ const agentId = requireValidId(c, "agentId");
2256
+ const benchmarkId = requireValidId(c, "benchmarkId");
2257
+ const caseId = requireValidId(c, "caseId");
2258
+ deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
2259
+ await deps2.agentConfigService.requireExists(projectId, agentId);
2260
+ const download = c.req.query("download") === "1";
2261
+ const boundedPreview = !download && c.req.query("preview") === "1";
2262
+ const { data, fileName, contentType, scriptable, truncated } = await deps2.benchmarks.readCaseFile(
2263
+ projectId,
2264
+ agentId,
2265
+ benchmarkId,
2266
+ caseId,
2267
+ c.req.query("path") ?? "",
2268
+ material,
2269
+ boundedPreview ? { maxBytes: TEXT_PREVIEW_BYTES } : void 0
2270
+ );
2271
+ return new Response(new Uint8Array(data), {
2272
+ status: 200,
2273
+ headers: {
2274
+ "Content-Type": !download && scriptable ? "text/plain; charset=utf-8" : contentType,
2275
+ "Content-Disposition": `${download ? "attachment" : "inline"}; filename*=UTF-8''${encodeURIComponent(fileName)}`,
2276
+ "X-Content-Type-Options": "nosniff",
2277
+ ...truncated ? { "X-Content-Truncated": "1" } : {}
2278
+ }
2279
+ });
2280
+ };
2281
+ }
2193
2282
  function benchmarksRoutes(deps2) {
2194
2283
  const app2 = new Hono10();
2195
2284
  app2.get("/", async (c) => {
@@ -2199,6 +2288,18 @@ function benchmarksRoutes(deps2) {
2199
2288
  await deps2.agentConfigService.requireExists(projectId, agentId);
2200
2289
  return c.json(await deps2.benchmarks.list(projectId, agentId));
2201
2290
  });
2291
+ app2.get("/:benchmarkId/cases", async (c) => {
2292
+ const projectId = requireValidId(c, "projectId");
2293
+ const agentId = requireValidId(c, "agentId");
2294
+ const benchmarkId = requireValidId(c, "benchmarkId");
2295
+ deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
2296
+ await deps2.agentConfigService.requireExists(projectId, agentId);
2297
+ return c.json(await deps2.benchmarks.listCases(projectId, agentId, benchmarkId));
2298
+ });
2299
+ app2.get("/:benchmarkId/cases/:caseId/files", listCaseFiles(deps2, "statement"));
2300
+ app2.get("/:benchmarkId/cases/:caseId/files/content", readCaseFile(deps2, "statement"));
2301
+ app2.get("/:benchmarkId/cases/:caseId/rubric/files", listCaseFiles(deps2, "rubric"));
2302
+ app2.get("/:benchmarkId/cases/:caseId/rubric/files/content", readCaseFile(deps2, "rubric"));
2202
2303
  return app2;
2203
2304
  }
2204
2305
 
@@ -2611,12 +2712,32 @@ function usageRoutes(deps2) {
2611
2712
  })
2612
2713
  );
2613
2714
  });
2715
+ app2.get("/errors", (c) => {
2716
+ const projectId = requireValidId(c, "projectId");
2717
+ deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
2718
+ const { offset, limit } = paginationQuery(c);
2719
+ const from = optionalDateParam(c.req.query("from"), "from");
2720
+ const to = optionalDateParam(c.req.query("to"), "to");
2721
+ const agentId = c.req.query("agentId");
2722
+ return c.json(
2723
+ deps2.usageService.queryErrors(projectId, {
2724
+ offset,
2725
+ limit,
2726
+ // Same admin-only rule as the dashboard: a regular member seeing another tenant's
2727
+ // unattributed errors would be a cross-tenant leak.
2728
+ includeGlobalErrors: c.var.user.isAdmin,
2729
+ ...from !== void 0 ? { from } : {},
2730
+ ...to !== void 0 ? { to } : {},
2731
+ ...agentId !== void 0 && agentId !== "" ? { agentId } : {}
2732
+ })
2733
+ );
2734
+ });
2614
2735
  return app2;
2615
2736
  }
2616
2737
 
2617
2738
  // src/http/routes/sessions.ts
2618
- import fs7 from "fs/promises";
2619
- import path6 from "path";
2739
+ import fs8 from "fs/promises";
2740
+ import path7 from "path";
2620
2741
  import { Hono as Hono18 } from "hono";
2621
2742
  import { imageUrlMessage, scratchpadDir, userText } from "@prismshadow/penguin-core";
2622
2743
 
@@ -2796,9 +2917,14 @@ var WorkspaceFilesService = class {
2796
2917
  );
2797
2918
  return unique.filter((_, i) => exists[i]);
2798
2919
  }
2799
- /** List a directory: dirs come first, each group sorted by name; kind follows the symlink target (consistent with read behavior). */
2920
+ /**
2921
+ * List a directory: dirs come first, each group sorted by name. Entries whose
2922
+ * canonical target leaves the Workspace are omitted, so listing cannot expose
2923
+ * metadata for an out-of-bounds symlink.
2924
+ */
2800
2925
  async list(workspace, rel) {
2801
2926
  const dir = await this.resolveRead(workspace, rel);
2927
+ const realBase = await this.realBase(workspace);
2802
2928
  let dirents;
2803
2929
  try {
2804
2930
  dirents = await fs6.readdir(dir, { withFileTypes: true });
@@ -2811,33 +2937,31 @@ var WorkspaceFilesService = class {
2811
2937
  }
2812
2938
  throw err;
2813
2939
  }
2814
- const entries = await Promise.all(
2940
+ const listed = await Promise.all(
2815
2941
  dirents.map(async (d) => {
2816
- let sizeBytes = 0;
2817
- let mtime = "";
2818
- let isDir = d.isDirectory();
2819
2942
  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();
2943
+ const canonical = await fs6.realpath(path5.join(dir, d.name));
2944
+ this.assertInside(canonical, realBase);
2945
+ const stat = await fs6.stat(canonical);
2946
+ return {
2947
+ name: d.name,
2948
+ kind: stat.isDirectory() ? "dir" : "file",
2949
+ sizeBytes: stat.size,
2950
+ mtime: stat.mtime.toISOString()
2951
+ };
2824
2952
  } catch {
2953
+ return null;
2825
2954
  }
2826
- return {
2827
- name: d.name,
2828
- kind: isDir ? "dir" : "file",
2829
- sizeBytes,
2830
- mtime
2831
- };
2832
2955
  })
2833
2956
  );
2957
+ const entries = listed.filter((entry) => entry !== null);
2834
2958
  entries.sort(
2835
2959
  (a, b) => a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === "dir" ? -1 : 1
2836
2960
  );
2837
2961
  return { path: rel, entries };
2838
2962
  }
2839
2963
  /** Read a file (preview/download): IO on the canonical path (resolveRead has already eliminated symlink escapes). */
2840
- async read(workspace, rel) {
2964
+ async read(workspace, rel, options) {
2841
2965
  const file = await this.resolveRead(workspace, rel);
2842
2966
  let stat;
2843
2967
  try {
@@ -2846,16 +2970,35 @@ var WorkspaceFilesService = class {
2846
2970
  throw new HttpError(404, "path_not_found", "File does not exist.");
2847
2971
  }
2848
2972
  if (stat.isDirectory()) throw badRequest("path is a directory.");
2849
- if (stat.size > MAX_READ_BYTES) {
2973
+ const maxBytes = options?.maxBytes;
2974
+ if (maxBytes !== void 0 && (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_READ_BYTES)) {
2975
+ throw badRequest("maxBytes must be a positive integer within the read limit.");
2976
+ }
2977
+ if (maxBytes === void 0 && stat.size > MAX_READ_BYTES) {
2850
2978
  throw new HttpError(413, "file_too_large", "File exceeds the 50MB read limit.");
2851
2979
  }
2852
- const data = await fs6.readFile(file);
2980
+ let data;
2981
+ let truncated = false;
2982
+ if (maxBytes !== void 0 && stat.size > maxBytes) {
2983
+ const handle = await fs6.open(file, "r");
2984
+ try {
2985
+ const buffer = Buffer.alloc(maxBytes);
2986
+ const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0);
2987
+ data = buffer.subarray(0, bytesRead);
2988
+ truncated = true;
2989
+ } finally {
2990
+ await handle.close();
2991
+ }
2992
+ } else {
2993
+ data = await fs6.readFile(file);
2994
+ }
2853
2995
  const ext = path5.extname(file).toLowerCase();
2854
2996
  return {
2855
2997
  data,
2856
2998
  fileName: path5.basename(file),
2857
2999
  contentType: CONTENT_TYPES[ext] ?? "application/octet-stream",
2858
- scriptable: ext === ".html" || ext === ".htm" || ext === ".svg"
3000
+ scriptable: ext === ".html" || ext === ".htm" || ext === ".svg",
3001
+ ...truncated ? { truncated: true } : {}
2859
3002
  };
2860
3003
  }
2861
3004
  /**
@@ -2897,6 +3040,143 @@ var WorkspaceFilesService = class {
2897
3040
  }
2898
3041
  };
2899
3042
 
3043
+ // src/services/task-attachments.ts
3044
+ import fs7 from "fs/promises";
3045
+ import path6 from "path";
3046
+ import { randomBytes as randomBytes4 } from "crypto";
3047
+ import {
3048
+ appendAttachmentLines,
3049
+ attachedFileLine,
3050
+ modelVisiblePath
3051
+ } from "@prismshadow/penguin-core";
3052
+ var MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
3053
+ var MAX_ATTACHMENT_COUNT = 20;
3054
+ var MAX_TOTAL_ATTACHMENT_BYTES = 12 * 1024 * 1024;
3055
+ var MAX_STEM_BYTES = 80;
3056
+ var WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
3057
+ var INVISIBLE_CHAR = new RegExp("\\p{C}", "u");
3058
+ function unsafeNameChar(ch) {
3059
+ if (/[A-Za-z0-9._-]/.test(ch)) return false;
3060
+ return ch.codePointAt(0) < 128 || INVISIBLE_CHAR.test(ch);
3061
+ }
3062
+ function sanitizeSegment(value) {
3063
+ return Array.from(value, (ch) => unsafeNameChar(ch) ? "-" : ch).join("");
3064
+ }
3065
+ function truncateBytes(value, maxBytes) {
3066
+ if (Buffer.byteLength(value) <= maxBytes) return value;
3067
+ let out = "";
3068
+ for (const ch of value) {
3069
+ if (Buffer.byteLength(out) + Buffer.byteLength(ch) > maxBytes) break;
3070
+ out += ch;
3071
+ }
3072
+ return out;
3073
+ }
3074
+ var MAX_NAME_ATTEMPTS = 16;
3075
+ function parseAttachmentPart(part, index, field = "input") {
3076
+ const fileName = part.fileName;
3077
+ if (typeof fileName !== "string" || fileName.length === 0 || fileName.includes("/") || fileName.includes("\\") || fileName.includes("..") || fileName.includes("\0")) {
3078
+ throw badRequest(
3079
+ `${field}[${index}].fileName must be a non-empty file name without path separators or "..".`
3080
+ );
3081
+ }
3082
+ const dataUrl = part.dataUrl;
3083
+ const match = typeof dataUrl === "string" ? /^data:[^,]*;base64,([A-Za-z0-9+/=\s]+)$/.exec(dataUrl) : null;
3084
+ if (!match) {
3085
+ throw badRequest(`${field}[${index}].dataUrl must be a base64 data: URL of the file's bytes.`);
3086
+ }
3087
+ const bytes = Buffer.from(match[1], "base64");
3088
+ if (bytes.length === 0) {
3089
+ throw badRequest(`${field}[${index}].dataUrl decodes to an empty file.`);
3090
+ }
3091
+ if (bytes.length > MAX_ATTACHMENT_BYTES) {
3092
+ throw new HttpError(
3093
+ 413,
3094
+ "file_too_large",
3095
+ `Attached file exceeds the ${MAX_ATTACHMENT_BYTES / (1024 * 1024)}MB limit.`
3096
+ );
3097
+ }
3098
+ return { fileName, bytes };
3099
+ }
3100
+ function assertAttachmentBudget(attachments) {
3101
+ if (attachments.length > MAX_ATTACHMENT_COUNT) {
3102
+ throw new HttpError(
3103
+ 413,
3104
+ "too_many_files",
3105
+ `A message may carry at most ${MAX_ATTACHMENT_COUNT} attached files.`
3106
+ );
3107
+ }
3108
+ let total = 0;
3109
+ for (const a of attachments) total += a.bytes.length;
3110
+ if (total > MAX_TOTAL_ATTACHMENT_BYTES) {
3111
+ throw new HttpError(
3112
+ 413,
3113
+ "payload_too_large",
3114
+ `Attached files exceed the ${MAX_TOTAL_ATTACHMENT_BYTES / (1024 * 1024)}MB total limit for one message.`
3115
+ );
3116
+ }
3117
+ }
3118
+ function scratchpadName(fileName) {
3119
+ const ext = sanitizeSegment(path6.extname(fileName));
3120
+ const rawStem = fileName.slice(0, fileName.length - path6.extname(fileName).length);
3121
+ const stem = truncateBytes(sanitizeSegment(rawStem), MAX_STEM_BYTES).replace(/[. ]+$/, "");
3122
+ if (!stem || /^-+$/.test(stem)) return `file${ext}`;
3123
+ return WINDOWS_RESERVED.test(stem) ? `_${stem}${ext}` : `${stem}${ext}`;
3124
+ }
3125
+ async function writeAttachment(dir, attachment) {
3126
+ const base = scratchpadName(attachment.fileName);
3127
+ const ext = path6.extname(base);
3128
+ const stem = base.slice(0, base.length - ext.length);
3129
+ for (let attempt = 0; attempt < MAX_NAME_ATTEMPTS; attempt++) {
3130
+ const name = attempt === 0 ? base : `${stem}-${randomBytes4(3).toString("hex")}${ext}`;
3131
+ const file = path6.join(dir, name);
3132
+ try {
3133
+ await fs7.writeFile(file, attachment.bytes, { flag: "wx" });
3134
+ return file;
3135
+ } catch (err) {
3136
+ if (err.code !== "EEXIST") throw err;
3137
+ }
3138
+ }
3139
+ throw new Error(
3140
+ `failed to allocate a unique attachment file name under ${dir} after ${MAX_NAME_ATTEMPTS} attempts`
3141
+ );
3142
+ }
3143
+ async function openScratchpadDir(root, sessionId) {
3144
+ const dir = path6.join(root, sessionId);
3145
+ await fs7.mkdir(dir, { recursive: true });
3146
+ const canonicalRoot = await fs7.realpath(root);
3147
+ const rel = path6.relative(canonicalRoot, await fs7.realpath(dir));
3148
+ if (rel !== sessionId) {
3149
+ throw new Error(
3150
+ `session scratchpad ${dir} resolves outside the agent scratchpad root; refusing to write attachments`
3151
+ );
3152
+ }
3153
+ return dir;
3154
+ }
3155
+ async function removeAttachments(files) {
3156
+ await Promise.all(files.map((f) => fs7.rm(f, { force: true }).catch(() => {
3157
+ })));
3158
+ }
3159
+ async function attachFilesToInput(messages, attachments, root, sessionId) {
3160
+ if (attachments.length === 0) return { input: messages, written: [] };
3161
+ const dir = await openScratchpadDir(root, sessionId);
3162
+ const written = [];
3163
+ try {
3164
+ for (const attachment of attachments) {
3165
+ written.push(await writeAttachment(dir, attachment));
3166
+ }
3167
+ } catch (err) {
3168
+ await removeAttachments(written);
3169
+ throw err;
3170
+ }
3171
+ return {
3172
+ input: appendAttachmentLines(
3173
+ messages,
3174
+ written.map((filePath) => attachedFileLine(modelVisiblePath(filePath)))
3175
+ ),
3176
+ written
3177
+ };
3178
+ }
3179
+
2900
3180
  // src/http/routes/sessions.ts
2901
3181
  var SESSION_TITLE_MAX = 120;
2902
3182
  var STAT_MAX_PATHS = 100;
@@ -2914,12 +3194,33 @@ var SESSION_CATEGORIES = [
2914
3194
  "schedule",
2915
3195
  "archived"
2916
3196
  ];
3197
+ var IMAGE_DATA_URL = /^data:[^;,]+;base64,[A-Za-z0-9+/=\s]+$/;
3198
+ function requireImageUrl(url, field) {
3199
+ if (typeof url === "string") {
3200
+ if (url.startsWith("http://") || url.startsWith("https://")) return url;
3201
+ if (IMAGE_DATA_URL.test(url)) return url;
3202
+ }
3203
+ throw badRequest(
3204
+ `${field} must be an http(s) URL or a base64 data: URL (data:<mime>;base64,<bytes>).`
3205
+ );
3206
+ }
3207
+ function resolveScratchpadFile(dir, fileName) {
3208
+ if (!fileName || fileName === "." || fileName === "..") return null;
3209
+ for (const ch of fileName) {
3210
+ const code = ch.codePointAt(0);
3211
+ if (code < 32 || code === 127 || ch === "/" || ch === "\\") return null;
3212
+ }
3213
+ const resolved = path7.resolve(dir, fileName);
3214
+ return path7.dirname(resolved) === path7.resolve(dir) ? resolved : null;
3215
+ }
2917
3216
  function parseTaskInput(body) {
2918
3217
  const input = body.input;
2919
3218
  if (!Array.isArray(input) || input.length === 0) {
2920
3219
  throw badRequest("input must be an array with at least one item.");
2921
3220
  }
2922
- return input.map((item, i) => {
3221
+ const messages = [];
3222
+ const attachments = [];
3223
+ input.forEach((item, i) => {
2923
3224
  if (item === null || typeof item !== "object" || Array.isArray(item)) {
2924
3225
  throw badRequest(`input[${i}] must be an object.`);
2925
3226
  }
@@ -2928,17 +3229,41 @@ function parseTaskInput(body) {
2928
3229
  if (typeof part.text !== "string" || part.text.length === 0) {
2929
3230
  throw badRequest(`input[${i}].text must be a non-empty string.`);
2930
3231
  }
2931
- return userText(part.text);
3232
+ messages.push(userText(part.text));
3233
+ return;
2932
3234
  }
2933
3235
  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);
3236
+ messages.push(imageUrlMessage(requireImageUrl(part.imageUrl, `input[${i}].imageUrl`)));
3237
+ return;
3238
+ }
3239
+ if (part.type === "file") {
3240
+ attachments.push(parseAttachmentPart(part, i));
3241
+ assertAttachmentBudget(attachments);
3242
+ return;
2939
3243
  }
2940
- throw badRequest(`input[${i}].type must be one of text / image_url.`);
3244
+ throw badRequest(`input[${i}].type must be one of text / image_url / file.`);
2941
3245
  });
3246
+ return { messages, attachments };
3247
+ }
3248
+ function parseSteerImages(body) {
3249
+ const images = body.images;
3250
+ if (images === void 0) return [];
3251
+ if (!Array.isArray(images)) throw badRequest("images must be an array.");
3252
+ return images.map((url, i) => requireImageUrl(url, `images[${i}]`));
3253
+ }
3254
+ function parseSteerFiles(body) {
3255
+ const files = body.files;
3256
+ if (files === void 0) return [];
3257
+ if (!Array.isArray(files)) throw badRequest("files must be an array.");
3258
+ const attachments = [];
3259
+ files.forEach((item, i) => {
3260
+ if (item === null || typeof item !== "object" || Array.isArray(item)) {
3261
+ throw badRequest(`files[${i}] must be an object.`);
3262
+ }
3263
+ attachments.push(parseAttachmentPart(item, i, "files"));
3264
+ assertAttachmentBudget(attachments);
3265
+ });
3266
+ return attachments;
2942
3267
  }
2943
3268
  function parseGoalField(body) {
2944
3269
  const goal = body.goal;
@@ -2966,13 +3291,16 @@ function agentSessionsRoutes(deps2) {
2966
3291
  }
2967
3292
  const rawCounts = c.req.query("counts");
2968
3293
  if (rawCounts !== void 0 && rawCounts !== "1") throw badRequest("counts only accepts 1.");
3294
+ const rawCli = c.req.query("cli");
3295
+ if (rawCli !== void 0 && rawCli !== "1") throw badRequest("cli only accepts 1.");
2969
3296
  const { sessions, counts, workspaceCounts } = await deps2.sessionService.listSessions(
2970
3297
  projectId,
2971
3298
  agentId,
2972
3299
  {
2973
3300
  ...paging ? { paging } : {},
2974
3301
  ...rawCategory !== void 0 ? { category: rawCategory } : {},
2975
- ...rawCounts !== void 0 ? { withCounts: true } : {}
3302
+ ...rawCounts !== void 0 ? { withCounts: true } : {},
3303
+ ...rawCli !== void 0 ? { includeCli: true } : {}
2976
3304
  }
2977
3305
  );
2978
3306
  return c.json({
@@ -3101,8 +3429,8 @@ function sessionsRoutes(deps2) {
3101
3429
  ]);
3102
3430
  }
3103
3431
  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),
3432
+ await fs8.rm(
3433
+ path7.join(scratchpadDir(deps2.config.root, row.projectId, row.agentId), row.sessionId),
3106
3434
  { recursive: true, force: true }
3107
3435
  );
3108
3436
  deps2.sessionsRepo.deleteById(row.sessionId);
@@ -3116,17 +3444,14 @@ function sessionsRoutes(deps2) {
3116
3444
  app2.get("/:sessionId/scratchpad/:fileName", async (c) => {
3117
3445
  const row = resolveSession(c);
3118
3446
  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,
3447
+ const filePath = resolveScratchpadFile(
3448
+ path7.join(scratchpadDir(deps2.config.root, row.projectId, row.agentId), row.sessionId),
3125
3449
  fileName
3126
3450
  );
3451
+ if (!filePath) throw new HttpError(404, "file_not_found", "File does not exist.");
3127
3452
  let bytes;
3128
3453
  try {
3129
- bytes = await fs7.readFile(filePath);
3454
+ bytes = await fs8.readFile(filePath);
3130
3455
  } catch {
3131
3456
  throw new HttpError(404, "file_not_found", "File does not exist.");
3132
3457
  }
@@ -3137,9 +3462,21 @@ function sessionsRoutes(deps2) {
3137
3462
  ".gif": "image/gif",
3138
3463
  ".webp": "image/webp"
3139
3464
  };
3140
- const mime = MIME_BY_EXT[path6.extname(fileName).toLowerCase()] ?? "application/octet-stream";
3465
+ const mime = MIME_BY_EXT[path7.extname(fileName).toLowerCase()];
3141
3466
  return c.body(new Uint8Array(bytes), 200, {
3142
- "content-type": mime,
3467
+ "content-type": mime ?? "application/octet-stream",
3468
+ // nosniff: the composer's file attachments land in this same directory, so the bytes
3469
+ // here are arbitrary user content served from the App's own origin — without it a
3470
+ // browser could sniff an `application/octet-stream` upload back into HTML and run it
3471
+ // same-origin (the same defense workspace file reads apply).
3472
+ "x-content-type-options": "nosniff",
3473
+ // Second, independent layer for everything that fell off the allowlist: the only reason
3474
+ // this endpoint is fetched inline is the conversation's <img> tags, so anything that is
3475
+ // not one of those images is served as a download and never renders as a document —
3476
+ // nosniff alone would be the whole defense otherwise.
3477
+ ...mime === void 0 ? {
3478
+ "content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`
3479
+ } : {},
3143
3480
  "cache-control": "private, max-age=31536000, immutable"
3144
3481
  });
3145
3482
  });
@@ -3162,11 +3499,15 @@ function sessionsRoutes(deps2) {
3162
3499
  app2.get("/:sessionId/stream", (c) => {
3163
3500
  const row = resolveSession(c);
3164
3501
  const channel = deps2.channels.get(row.sessionId);
3502
+ const pendingSteering = deps2.manager.pendingSteeringOf(row.sessionId);
3165
3503
  const initialEvents = [
3166
3504
  {
3167
3505
  type: "task_state",
3168
3506
  state: deps2.manager.statusOf(row.sessionId),
3169
- queued: deps2.manager.pendingFollowUpCount(row.sessionId)
3507
+ queued: deps2.manager.pendingFollowUpCount(row.sessionId),
3508
+ // Undelivered steering rides the snapshot too, so the composer's "steering queued"
3509
+ // hint (and what it says) survives a reload.
3510
+ ...pendingSteering.length > 0 ? { pendingSteering } : {}
3170
3511
  },
3171
3512
  ...deps2.manager.pendingApprovals(row.sessionId).map((p) => ({
3172
3513
  type: "approval_request",
@@ -3182,32 +3523,66 @@ function sessionsRoutes(deps2) {
3182
3523
  const goal = parseGoalField(body);
3183
3524
  const thinkingLevel = optionalEnum(body, "thinkingLevel", THINKING_LEVELS);
3184
3525
  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).");
3526
+ const { messages, attachments } = parseTaskInput(body);
3527
+ const text = messages.filter((m) => m.payload.type === "text").map((m) => m.payload.text).join("\n").trim();
3528
+ if (!text) {
3529
+ throw badRequest("goal mode requires a non-empty text objective.");
3189
3530
  }
3190
- const { sessionId: sessionId2 } = await deps2.manager.startGoal(row.sessionId, {
3191
- input: input2,
3531
+ if (attachments.length > 0) {
3532
+ throw badRequest("goal mode accepts text and images only (no file attachments).");
3533
+ }
3534
+ const { sessionId } = await deps2.manager.startGoal(row.sessionId, {
3535
+ input: messages,
3192
3536
  budget: goal.budget,
3193
3537
  ...thinkingLevel !== void 0 ? { thinkingLevel } : {}
3194
3538
  });
3195
- return c.json({ sessionId: sessionId2 }, 202);
3539
+ return c.json({ sessionId }, 202);
3196
3540
  }
3197
- const input = parseTaskInput(body);
3541
+ const parsed = parseTaskInput(body);
3198
3542
  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);
3543
+ deps2.manager.assertCanAcceptTask(row.sessionId, { queueIfBusy });
3544
+ const { input, written } = await attachFilesToInput(
3545
+ parsed.messages,
3546
+ parsed.attachments,
3547
+ scratchpadDir(deps2.config.root, row.projectId, row.agentId),
3548
+ row.sessionId
3549
+ );
3550
+ try {
3551
+ const { sessionId, queued } = await deps2.manager.startTask(row.sessionId, input, {
3552
+ ...thinkingLevel !== void 0 ? { thinkingLevel } : {},
3553
+ queueIfBusy
3554
+ });
3555
+ return c.json({ sessionId, queued }, 202);
3556
+ } catch (err) {
3557
+ await removeAttachments(written);
3558
+ throw err;
3559
+ }
3204
3560
  });
3205
3561
  app2.post("/:sessionId/steer", async (c) => {
3206
3562
  const row = resolveSession(c);
3207
3563
  const body = await readJson(c);
3208
3564
  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);
3565
+ const images = parseSteerImages(body);
3566
+ const files = parseSteerFiles(body);
3567
+ if (!text && images.length === 0 && files.length === 0) {
3568
+ throw badRequest("text, images or files must carry the steering message.");
3569
+ }
3570
+ const { input, written } = await attachFilesToInput(
3571
+ [...text ? [userText(text)] : [], ...images.map((url) => imageUrlMessage(url))],
3572
+ files,
3573
+ scratchpadDir(deps2.config.root, row.projectId, row.agentId),
3574
+ row.sessionId
3575
+ );
3576
+ try {
3577
+ deps2.manager.steer(row.sessionId, input, {
3578
+ text,
3579
+ images: images.length,
3580
+ files: files.length
3581
+ });
3582
+ } catch (err) {
3583
+ await removeAttachments(written);
3584
+ throw err;
3585
+ }
3211
3586
  return c.body(null, 202);
3212
3587
  });
3213
3588
  app2.get("/:sessionId/goal", (c) => {
@@ -3661,8 +4036,8 @@ var ErrorRecorder = class {
3661
4036
  };
3662
4037
 
3663
4038
  // src/runtime/session-manager.ts
3664
- import fs8 from "fs/promises";
3665
- import path8 from "path";
4039
+ import fs9 from "fs/promises";
4040
+ import path9 from "path";
3666
4041
  import {
3667
4042
  createAgent,
3668
4043
  findLatestTraceFile,
@@ -3670,6 +4045,7 @@ import {
3670
4045
  goalTokenDelta,
3671
4046
  isGoalRoundInput,
3672
4047
  isSessionMeta as isSessionMeta2,
4048
+ parseUserSteeringText,
3673
4049
  stripLeadingMarkerBlocks,
3674
4050
  tracesDir
3675
4051
  } from "@prismshadow/penguin-core";
@@ -3922,14 +4298,26 @@ var SessionSources = class {
3922
4298
 
3923
4299
  // src/runtime/stream-error-watcher.ts
3924
4300
  import { isEventMessage, isModelMessage, isSessionMeta } from "@prismshadow/penguin-core";
3925
- import path7 from "path";
4301
+ import path8 from "path";
3926
4302
  var TOOL_NAMES_MAX = 1e3;
3927
4303
  var ORIGIN_CTX_MAX = 200;
3928
4304
  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)." },
4305
+ // Reached here only when no further attempt followed: the ladder ran out (or the run ended
4306
+ // on it), so the user lost the turn and someone should look. A `failed` that was retried
4307
+ // takes LLM_FAILED_RETRIED instead.
4308
+ failed: {
4309
+ code: "llm_failed",
4310
+ kind: "unexpected",
4311
+ text: "LLM request failed and the retries did not recover it."
4312
+ },
4313
+ // Its own code, not the failed bucket: dedup is `(source, code, Project)` over a short
4314
+ // window, and `llm_failed_retried` can now fire on any recovered blip — sharing a bucket
4315
+ // would let a genuine credential failure be dropped as a duplicate of one.
4316
+ auth: {
4317
+ code: "llm_auth",
4318
+ kind: "unexpected",
4319
+ text: "LLM request rejected: the provider did not accept the credentials."
4320
+ },
3933
4321
  timeout: {
3934
4322
  code: "llm_timeout",
3935
4323
  kind: "expected",
@@ -3941,12 +4329,23 @@ var LLM_FAILURES = {
3941
4329
  text: "LLM response could not be parsed (the engine reconnects and retries)."
3942
4330
  }
3943
4331
  };
4332
+ var LLM_FAILED_RETRIED = {
4333
+ code: "llm_failed_retried",
4334
+ kind: "expected",
4335
+ text: "LLM request failed (the engine reconnects and retries)."
4336
+ };
3944
4337
  function isLlmFailure(s) {
3945
4338
  return s === "failed" || s === "timeout" || s === "malformed" || s === "auth";
3946
4339
  }
3947
4340
  function isToolFailure(s) {
3948
4341
  return s === "failed" || s === "timeout";
3949
4342
  }
4343
+ var COMMAND_TOOLS = /* @__PURE__ */ new Set(["exec_command", "input_command"]);
4344
+ var ORDINARY_EXIT_NOTE = /\[exit code: [^\]\n]*\]\s*$/;
4345
+ function isOrdinaryCommandExit(name, output) {
4346
+ if (name !== void 0 && !COMMAND_TOOLS.has(name)) return false;
4347
+ return ORDINARY_EXIT_NOTE.test(output);
4348
+ }
3950
4349
  function isUserAbortReason(reason) {
3951
4350
  return /^aborted\b/i.test(reason);
3952
4351
  }
@@ -4004,7 +4403,7 @@ var StreamErrorWatcher = class {
4004
4403
  registerOrigin(msg) {
4005
4404
  const key = originKey(msg);
4006
4405
  if (!key) return;
4007
- const agentId = path7.basename(path7.dirname(msg.payload.agent_state));
4406
+ const agentId = path8.basename(path8.dirname(msg.payload.agent_state));
4008
4407
  if (!agentId || agentId === "." || agentId === "..") return;
4009
4408
  this.originCtx.delete(key);
4010
4409
  this.originCtx.set(key, { agentId, sessionId: key });
@@ -4041,7 +4440,7 @@ var StreamErrorWatcher = class {
4041
4440
  return;
4042
4441
  }
4043
4442
  if (p.type === "request_begin") {
4044
- this.flush(key);
4443
+ this.flush(key, null, true);
4045
4444
  return;
4046
4445
  }
4047
4446
  if (p.type === "abort") {
@@ -4050,15 +4449,21 @@ var StreamErrorWatcher = class {
4050
4449
  }
4051
4450
  /**
4052
4451
  * 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
4452
+ * message that arrived afterward, and `retried` says whether another attempt followed
4453
+ * (only a `request_begin` proves that). Pending state is already bucketed by origin, so
4054
4454
  * `key` is exactly "the session that produced this failure" — attribution is looked
4055
4455
  * up from it (see file header).
4456
+ *
4457
+ * `retried` defaults to false so every other resolution — an abort, the defensive flush at
4458
+ * the next request_end, or close() at the end of the run — is read as "nothing came after
4459
+ * this failure". That is the conservative direction: at worst a recovered blip is escalated,
4460
+ * never a lost turn silently downgraded.
4056
4461
  */
4057
- flush(key, reason) {
4462
+ flush(key, reason, retried = false) {
4058
4463
  const entry = this.pending.get(key);
4059
4464
  if (entry === void 0) return;
4060
4465
  this.pending.delete(key);
4061
- const spec = LLM_FAILURES[entry.status];
4466
+ const spec = retried && entry.status === "failed" ? LLM_FAILED_RETRIED : LLM_FAILURES[entry.status];
4062
4467
  const trimmed = reason?.trim();
4063
4468
  const message = trimmed && !isUserAbortReason(trimmed) ? trimmed : entry.message ?? spec.text;
4064
4469
  this.errors.record({
@@ -4088,9 +4493,11 @@ var StreamErrorWatcher = class {
4088
4493
  const name = this.toolNames.get(key);
4089
4494
  this.toolNames.delete(key);
4090
4495
  if (!isToolFailure(p.stop_reason)) return;
4496
+ const output = p.output ?? "";
4497
+ if (isOrdinaryCommandExit(name, output)) return;
4091
4498
  this.errors.record({
4092
4499
  source: "environment",
4093
- err: toolFailureText(p.output ?? ""),
4500
+ err: toolFailureText(output),
4094
4501
  ctx: this.ctxFor(origin),
4095
4502
  // The tool name goes into code: so the stats dashboard's "most common error code" and table can show which tool failed.
4096
4503
  code: `tool_${p.stop_reason}:${name ?? "unknown"}`,
@@ -4129,7 +4536,7 @@ function createCoreSessionLoader(root, sources) {
4129
4536
  }
4130
4537
  }
4131
4538
  try {
4132
- const stat = await fs8.stat(row.workspace);
4539
+ const stat = await fs9.stat(row.workspace);
4133
4540
  if (!stat.isDirectory()) throw new Error("not a directory");
4134
4541
  } catch {
4135
4542
  throw new HttpError(
@@ -4169,6 +4576,11 @@ var EARLY_TITLE_BODY_CHARS = 1e3;
4169
4576
  function agentKey(projectId, agentId) {
4170
4577
  return `${projectId}\0${agentId}`;
4171
4578
  }
4579
+ function isDeliveredSteering(msg) {
4580
+ const p = msg.payload;
4581
+ if (msg.type !== "model_msg" || p.type !== "text" || p.role !== "user") return false;
4582
+ return typeof p.text === "string" && parseUserSteeringText(p.text) !== null;
4583
+ }
4172
4584
  function runSubagentCall(msg) {
4173
4585
  const p = msg.payload;
4174
4586
  if (msg.type !== "model_msg" || p.type !== "tool_call" || p.name !== "run_subagent") return null;
@@ -4241,6 +4653,10 @@ var SessionManager = class {
4241
4653
  pendingFollowUpCount(sessionId) {
4242
4654
  return this.entries.get(sessionId)?.followUps.length ?? 0;
4243
4655
  }
4656
+ /** Steering messages queued but not yet delivered to the model (display mirror; see RuntimeEntry.pendingSteering). */
4657
+ pendingSteeringOf(sessionId) {
4658
+ return this.entries.get(sessionId)?.pendingSteering ?? [];
4659
+ }
4244
4660
  /**
4245
4661
  * Live tail of a running session: one synthetic `partial_* start` OmniMessage per open
4246
4662
  * streaming fragment, carrying the full accumulated content so far (see live-tail.ts).
@@ -4273,6 +4689,7 @@ var SessionManager = class {
4273
4689
  running: null,
4274
4690
  generation: this.generationOf(row.projectId, row.agentId),
4275
4691
  followUps: [],
4692
+ pendingSteering: [],
4276
4693
  lastActivityMs: Date.now()
4277
4694
  });
4278
4695
  }
@@ -4305,6 +4722,23 @@ var SessionManager = class {
4305
4722
  for (const agentId of agentIds) this.invalidateAgentRuntimes(projectId, agentId);
4306
4723
  }
4307
4724
  // —— Task / compaction drive ——
4725
+ /**
4726
+ * Cheap, lock-free rehearsal of the 409/503 conditions startTask checks, throwing exactly the
4727
+ * same HttpErrors. **Advisory only**: it neither takes the Session lock nor loads an entry, so
4728
+ * a session that isn't in the active table reads as acceptable and a status change racing this
4729
+ * call is not caught — the authoritative check is still the one inside startTask.
4730
+ *
4731
+ * It exists so a caller that has irreversible work to do first (POST /tasks writes the
4732
+ * message's file attachments to disk) can find out about the ordinary "a Task is already
4733
+ * running" rejection before doing it, instead of undoing it afterwards.
4734
+ */
4735
+ assertCanAcceptTask(sessionId, opts) {
4736
+ this.assertOpen();
4737
+ this.assertAgentNotDeleting(sessionId);
4738
+ this.assertSessionNotDeleting(sessionId);
4739
+ const entry = this.entries.get(sessionId);
4740
+ if (entry && !opts?.queueIfBusy) this.assertIdle(entry);
4741
+ }
4308
4742
  /**
4309
4743
  * Start a Task: get-or-load → 409
4310
4744
  * mutual-exclusion check → publish the input messages first → drive run in the
@@ -4543,22 +4977,30 @@ var SessionManager = class {
4543
4977
  return entry.approvals.decide(toolCallId, decision);
4544
4978
  }
4545
4979
  /**
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.
4980
+ * Mid-run steering: forward the message to the running Session (core delivers it between
4981
+ * turns as a standalone `[user_steering]` user message followed by its images — no SSE
4982
+ * event of its own; the messages arrive through the stream the drive loop already
4983
+ * publishes). 409 when the Session isn't running a Task (idle / compacting / not loaded)
4984
+ * or the run finished in the race window — the caller falls back to submitting a normal
4985
+ * task, which carries the same text and images.
4986
+ *
4987
+ * `info` is the queued message's display summary: mirrored on the entry and broadcast via
4988
+ * `task_state` (and the SSE subscribe snapshot) until the delivered `[user_steering]`
4989
+ * message is observed on the stream — that is what keeps the composer's "steering queued"
4990
+ * hint, content included, alive across reloads.
4551
4991
  */
4552
- steer(sessionId, text) {
4992
+ steer(sessionId, input, info) {
4553
4993
  const entry = this.entries.get(sessionId);
4554
- if (!entry || entry.status !== "running" || !entry.session.steer(text)) {
4994
+ if (!entry || entry.status !== "running" || !entry.session.steer(input)) {
4555
4995
  throw new HttpError(
4556
4996
  409,
4557
4997
  "not_running",
4558
4998
  "This Session has no Task in progress; send the message as a new task instead."
4559
4999
  );
4560
5000
  }
5001
+ entry.pendingSteering.push(info);
4561
5002
  entry.lastActivityMs = Date.now();
5003
+ this.publishState(entry, entry.status);
4562
5004
  }
4563
5005
  /**
4564
5006
  * "Retry now" on the reconnect countdown: skip the in-progress backoff wait and fire
@@ -4773,6 +5215,7 @@ var SessionManager = class {
4773
5215
  running: null,
4774
5216
  generation,
4775
5217
  followUps: [],
5218
+ pendingSteering: [],
4776
5219
  lastActivityMs: Date.now()
4777
5220
  };
4778
5221
  this.entries.set(currentId, entry);
@@ -4785,6 +5228,7 @@ var SessionManager = class {
4785
5228
  * a title): it collects model text for automatic title generation.
4786
5229
  */
4787
5230
  async drive(entry, gen, titleSource) {
5231
+ this.deps.sessions.markHasTrace(entry.sessionId);
4788
5232
  let earlyTitleFired = false;
4789
5233
  let mainBodyChars = 0;
4790
5234
  const ctx = {
@@ -4810,6 +5254,10 @@ var SessionManager = class {
4810
5254
  if (denied) subagentPrompts.delete(denied);
4811
5255
  const settled = settledToolCallId(msg);
4812
5256
  if (settled) subagentPrompts.delete(settled);
5257
+ if (entry.pendingSteering.length > 0 && isDeliveredSteering(msg)) {
5258
+ entry.pendingSteering.shift();
5259
+ this.publishState(entry, entry.status);
5260
+ }
4813
5261
  } else if (isSessionMeta2(msg)) {
4814
5262
  try {
4815
5263
  const child = this.registerChildSession(entry, msg, children);
@@ -4872,6 +5320,7 @@ var SessionManager = class {
4872
5320
  entry.status = "idle";
4873
5321
  entry.abort = null;
4874
5322
  entry.running = null;
5323
+ entry.pendingSteering = [];
4875
5324
  entry.lastActivityMs = Date.now();
4876
5325
  this.publishState(entry, "idle");
4877
5326
  if (titleSource && titleSource.userExcerpt.trim()) {
@@ -4915,7 +5364,7 @@ var SessionManager = class {
4915
5364
  const childSid = msg.origin[msg.origin.length - 1];
4916
5365
  if (children.has(childSid)) return null;
4917
5366
  const p = msg.payload;
4918
- const agentId = path8.basename(path8.dirname(p.agent_state));
5367
+ const agentId = path9.basename(path9.dirname(p.agent_state));
4919
5368
  if (!agentId || agentId === "." || agentId === "..") return null;
4920
5369
  const source = asSessionSource(p.source) ?? "subagent";
4921
5370
  this.deps.sources.set(childSid, source);
@@ -4930,6 +5379,8 @@ var SessionManager = class {
4930
5379
  // inserted with defaults (matches the convention for Sessions discovered by the CLI).
4931
5380
  approvalMode: "allow-all",
4932
5381
  title: null,
5382
+ // Spawned by this server's run (client NULL = web); its Trace exists by construction.
5383
+ hasTrace: true,
4933
5384
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
4934
5385
  });
4935
5386
  this.publishEvent(entry, {
@@ -4950,7 +5401,12 @@ var SessionManager = class {
4950
5401
  return child;
4951
5402
  }
4952
5403
  publishState(entry, state) {
4953
- this.publishEvent(entry, { type: "task_state", state, queued: entry.followUps.length });
5404
+ this.publishEvent(entry, {
5405
+ type: "task_state",
5406
+ state,
5407
+ queued: entry.followUps.length,
5408
+ ...entry.pendingSteering.length > 0 ? { pendingSteering: entry.pendingSteering } : {}
5409
+ });
4954
5410
  }
4955
5411
  publishEvent(entry, event) {
4956
5412
  this.deps.channels.get(entry.sessionId).publish(event, "server_event");
@@ -5394,7 +5850,7 @@ var UsageRecorder = class {
5394
5850
  };
5395
5851
 
5396
5852
  // src/services/ids.ts
5397
- import { randomBytes as randomBytes4 } from "crypto";
5853
+ import { randomBytes as randomBytes5 } from "crypto";
5398
5854
  var SEMANTIC_ID_PATTERN = /^[a-z][a-z0-9_]{1,63}$/;
5399
5855
  var USERNAME_PATTERN = /^[a-z][a-z0-9_]{1,31}$/;
5400
5856
  var PROJECT_SUFFIX_PATTERN = /^[a-z0-9_]+$/;
@@ -5470,7 +5926,7 @@ var AdminService = class {
5470
5926
  };
5471
5927
 
5472
5928
  // src/services/agent-config-service.ts
5473
- import fs10 from "fs/promises";
5929
+ import fs11 from "fs/promises";
5474
5930
  import { parseDocument, parse as parseYaml } from "yaml";
5475
5931
  import {
5476
5932
  agentsMdPath,
@@ -5485,8 +5941,8 @@ import {
5485
5941
  } from "@prismshadow/penguin-core";
5486
5942
 
5487
5943
  // src/services/project-config-service.ts
5488
- import fs9 from "fs/promises";
5489
- import path9 from "path";
5944
+ import fs10 from "fs/promises";
5945
+ import path10 from "path";
5490
5946
  import { parse as parseToml2 } from "smol-toml";
5491
5947
  import {
5492
5948
  GenerativeModel,
@@ -5545,7 +6001,7 @@ var ProjectConfigService = class {
5545
6001
  */
5546
6002
  async configUpdatedAt(projectId) {
5547
6003
  try {
5548
- const st = await fs9.stat(this.filePath(projectId));
6004
+ const st = await fs10.stat(this.filePath(projectId));
5549
6005
  return st.mtime.toISOString();
5550
6006
  } catch {
5551
6007
  return void 0;
@@ -5555,7 +6011,7 @@ var ProjectConfigService = class {
5555
6011
  async readRaw(projectId) {
5556
6012
  let raw;
5557
6013
  try {
5558
- raw = await fs9.readFile(this.filePath(projectId), "utf8");
6014
+ raw = await fs10.readFile(this.filePath(projectId), "utf8");
5559
6015
  } catch (err) {
5560
6016
  if (err.code === "ENOENT") return {};
5561
6017
  throw err;
@@ -5570,9 +6026,9 @@ var ProjectConfigService = class {
5570
6026
  */
5571
6027
  async writeRaw(projectId, data) {
5572
6028
  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);
6029
+ await fs10.mkdir(path10.dirname(file), { recursive: true });
6030
+ await fs10.writeFile(file, renderProjectConfigToml(data), { encoding: "utf8", mode: 384 });
6031
+ await fs10.chmod(file, 384);
5576
6032
  }
5577
6033
  /**
5578
6034
  * Initial config for a newly created Project: display name + preset built-in
@@ -5616,6 +6072,15 @@ var ProjectConfigService = class {
5616
6072
  const raw = await this.readRaw(projectId);
5617
6073
  return typeof raw.name === "string" ? raw.name : void 0;
5618
6074
  }
6075
+ /**
6076
+ * Rewrites the display name, preserving every other field (models, credentials, default
6077
+ * model): read-modify-write of the same toml, like ensurePresetModels. The id itself is
6078
+ * immutable — only this label changes.
6079
+ */
6080
+ async setName(projectId, name) {
6081
+ const raw = await this.readRaw(projectId);
6082
+ await this.writeRaw(projectId, { ...raw, name });
6083
+ }
5619
6084
  /** Paired reference of the default Model; returns undefined if unconfigured (or in the old string format). */
5620
6085
  async getDefaultModelRef(projectId) {
5621
6086
  const raw = await this.readRaw(projectId);
@@ -5936,7 +6401,7 @@ var AgentConfigService = class {
5936
6401
  /** Whether the Agent exists (determined by the presence of system_config.yaml, matching the CLI's convention). */
5937
6402
  async exists(projectId, agentId) {
5938
6403
  try {
5939
- await fs10.access(systemConfigPath(this.root, projectId, agentId));
6404
+ await fs11.access(systemConfigPath(this.root, projectId, agentId));
5940
6405
  return true;
5941
6406
  } catch {
5942
6407
  return false;
@@ -5954,7 +6419,7 @@ var AgentConfigService = class {
5954
6419
  */
5955
6420
  async readCardMeta(projectId, agentId) {
5956
6421
  try {
5957
- const raw = await fs10.readFile(systemConfigPath(this.root, projectId, agentId), "utf8");
6422
+ const raw = await fs11.readFile(systemConfigPath(this.root, projectId, agentId), "utf8");
5958
6423
  const parsed = asRecord(parseYaml(raw));
5959
6424
  const tools = asRecord(parsed.tools);
5960
6425
  const countOf = (v) => Array.isArray(v) ? v.length : 0;
@@ -5972,14 +6437,14 @@ var AgentConfigService = class {
5972
6437
  async getConfig(projectId, agentId) {
5973
6438
  await this.requireExists(projectId, agentId);
5974
6439
  const yamlPath = systemConfigPath(this.root, projectId, agentId);
5975
- const systemConfigYaml = await fs10.readFile(yamlPath, "utf8");
6440
+ const systemConfigYaml = await fs11.readFile(yamlPath, "utf8");
5976
6441
  const parsed = asRecord(parseYaml(systemConfigYaml));
5977
6442
  const model = asRecord(parsed.model);
5978
6443
  const compaction = asRecord(parsed.compaction);
5979
6444
  const tools = asRecord(parsed.tools);
5980
6445
  let agentsMd = "";
5981
6446
  try {
5982
- agentsMd = await fs10.readFile(agentsMdPath(this.root, projectId, agentId), "utf8");
6447
+ agentsMd = await fs11.readFile(agentsMdPath(this.root, projectId, agentId), "utf8");
5983
6448
  } catch {
5984
6449
  }
5985
6450
  const modelDto = {
@@ -6022,7 +6487,7 @@ var AgentConfigService = class {
6022
6487
  await this.applyConfigUpdate(projectId, agentId, req.config);
6023
6488
  }
6024
6489
  if (req.agentsMd !== void 0) {
6025
- await fs10.writeFile(agentsMdPath(this.root, projectId, agentId), req.agentsMd, "utf8");
6490
+ await fs11.writeFile(agentsMdPath(this.root, projectId, agentId), req.agentsMd, "utf8");
6026
6491
  }
6027
6492
  }
6028
6493
  /**
@@ -6040,9 +6505,9 @@ var AgentConfigService = class {
6040
6505
  async applyConfigUpdate(projectId, agentId, config2) {
6041
6506
  const cfg = config2;
6042
6507
  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);
6508
+ const doc = parseDocument(await fs11.readFile(yamlPath, "utf8"));
6509
+ const setIfProvided = (path18, value) => {
6510
+ if (value !== void 0) doc.setIn(path18, value);
6046
6511
  };
6047
6512
  setIfProvided(["name"], optionalString(cfg, "name", { maxLen: 100, label: "name" }));
6048
6513
  setIfProvided(
@@ -6091,7 +6556,7 @@ var AgentConfigService = class {
6091
6556
  if (cfg.mcpServers !== void 0) {
6092
6557
  doc.setIn(["tools", "mcpServers"], validateMcpServers(cfg.mcpServers));
6093
6558
  }
6094
- await fs10.writeFile(yamlPath, doc.toString(), "utf8");
6559
+ await fs11.writeFile(yamlPath, doc.toString(), "utf8");
6095
6560
  }
6096
6561
  /** Read the Agent vault (agent_state/.vault.toml): values are always masked, plaintext is never sent to the client. */
6097
6562
  async getVault(projectId, agentId) {
@@ -6188,8 +6653,8 @@ function validateMcpServers(value) {
6188
6653
  }
6189
6654
 
6190
6655
  // src/services/agent-service.ts
6191
- import fs11 from "fs/promises";
6192
- import path10 from "path";
6656
+ import fs12 from "fs/promises";
6657
+ import path11 from "path";
6193
6658
  import {
6194
6659
  agentDir,
6195
6660
  agentsDir as agentsDir2,
@@ -6216,7 +6681,7 @@ var AgentService = class {
6216
6681
  const known = new Map(this.agents.list(projectId).map((r) => [r.agentId, r]));
6217
6682
  let entries = [];
6218
6683
  try {
6219
- const dirents = await fs11.readdir(agentsDir2(this.root, projectId), { withFileTypes: true });
6684
+ const dirents = await fs12.readdir(agentsDir2(this.root, projectId), { withFileTypes: true });
6220
6685
  entries = dirents.filter((d) => d.isDirectory()).map((d) => d.name);
6221
6686
  } catch {
6222
6687
  }
@@ -6225,7 +6690,7 @@ var AgentService = class {
6225
6690
  const configPath = systemConfigPath2(this.root, projectId, agentId);
6226
6691
  let createdAt;
6227
6692
  try {
6228
- const stat = await fs11.stat(configPath);
6693
+ const stat = await fs12.stat(configPath);
6229
6694
  createdAt = (stat.birthtime.getTime() > 0 ? stat.birthtime : stat.mtime).toISOString();
6230
6695
  } catch {
6231
6696
  continue;
@@ -6269,7 +6734,7 @@ var AgentService = class {
6269
6734
  /** Number of scheduled tasks: count of .toml files under schedule/ (0 if the directory doesn't exist). */
6270
6735
  async scheduleCount(projectId, agentId) {
6271
6736
  try {
6272
- const names = await fs11.readdir(scheduleDir2(this.root, projectId, agentId));
6737
+ const names = await fs12.readdir(scheduleDir2(this.root, projectId, agentId));
6273
6738
  return names.filter((n) => n.endsWith(".toml")).length;
6274
6739
  } catch {
6275
6740
  return 0;
@@ -6280,14 +6745,14 @@ var AgentService = class {
6280
6745
  const base = skillsDir2(this.root, projectId, agentId);
6281
6746
  let dirents;
6282
6747
  try {
6283
- dirents = await fs11.readdir(base, { withFileTypes: true });
6748
+ dirents = await fs12.readdir(base, { withFileTypes: true });
6284
6749
  } catch {
6285
6750
  return 0;
6286
6751
  }
6287
6752
  const present = await Promise.all(
6288
6753
  dirents.filter((d) => d.isDirectory()).map(async (d) => {
6289
6754
  try {
6290
- await fs11.access(path10.join(base, d.name, "SKILL.md"));
6755
+ await fs12.access(path11.join(base, d.name, "SKILL.md"));
6291
6756
  return true;
6292
6757
  } catch {
6293
6758
  return false;
@@ -6305,7 +6770,7 @@ var AgentService = class {
6305
6770
  const times = await Promise.all(
6306
6771
  paths.map(async (p) => {
6307
6772
  try {
6308
- return (await fs11.stat(p)).mtime.getTime();
6773
+ return (await fs12.stat(p)).mtime.getTime();
6309
6774
  } catch {
6310
6775
  return 0;
6311
6776
  }
@@ -6330,7 +6795,7 @@ var AgentService = class {
6330
6795
  "Built-in Agents (default_agent) are provisioned with the Project and cannot be deleted from the web."
6331
6796
  );
6332
6797
  }
6333
- await fs11.rm(agentDir(this.root, projectId, agentId), { recursive: true, force: true });
6798
+ await fs12.rm(agentDir(this.root, projectId, agentId), { recursive: true, force: true });
6334
6799
  this.agents.delete(projectId, agentId);
6335
6800
  }
6336
6801
  /**
@@ -6347,7 +6812,7 @@ var AgentService = class {
6347
6812
  `Agent id must be 2\u201364 characters: ${SEMANTIC_ID_RULE}.`
6348
6813
  );
6349
6814
  }
6350
- const taken = this.agents.exists(projectId, agentId) || await fs11.stat(agentDir(this.root, projectId, agentId)).then(
6815
+ const taken = this.agents.exists(projectId, agentId) || await fs12.stat(agentDir(this.root, projectId, agentId)).then(
6351
6816
  () => true,
6352
6817
  () => false
6353
6818
  );
@@ -6361,7 +6826,7 @@ var AgentService = class {
6361
6826
  config: { name: displayName, ...description !== void 0 ? { description } : {} }
6362
6827
  });
6363
6828
  } catch (err) {
6364
- await fs11.rm(agentDir(this.root, projectId, agentId), { recursive: true, force: true }).catch(() => {
6829
+ await fs12.rm(agentDir(this.root, projectId, agentId), { recursive: true, force: true }).catch(() => {
6365
6830
  });
6366
6831
  throw err;
6367
6832
  }
@@ -6385,120 +6850,223 @@ var AgentService = class {
6385
6850
  };
6386
6851
 
6387
6852
  // src/services/benchmark-service.ts
6388
- import fs12 from "fs/promises";
6389
- import path11 from "path";
6853
+ import fs13 from "fs/promises";
6854
+ import path12 from "path";
6390
6855
  import { parse as parseToml3 } from "smol-toml";
6391
6856
  import { parse as parseYaml2 } from "yaml";
6392
6857
  import { benchmarksDir } from "@prismshadow/penguin-core";
6858
+ var STATEMENT_TITLE_READ_BYTES = 64 * 1024;
6393
6859
  function asRecord2(v) {
6394
6860
  return v !== null && typeof v === "object" && !Array.isArray(v) ? v : {};
6395
6861
  }
6396
6862
  function numberOr(v) {
6397
6863
  return typeof v === "number" && Number.isFinite(v) ? v : void 0;
6398
6864
  }
6865
+ function scoreOr(v) {
6866
+ const value = numberOr(v);
6867
+ return value !== void 0 && value >= 0 && value <= 100 ? value : void 0;
6868
+ }
6869
+ function nonNegativeOr(v) {
6870
+ const value = numberOr(v);
6871
+ return value !== void 0 && value >= 0 ? value : void 0;
6872
+ }
6873
+ function nonNegativeIntegerOr(v) {
6874
+ const value = nonNegativeOr(v);
6875
+ return value !== void 0 && Number.isInteger(value) ? value : void 0;
6876
+ }
6877
+ function nullableCostOr(v) {
6878
+ if (v === null) return null;
6879
+ return nonNegativeOr(v);
6880
+ }
6399
6881
  function stringOr(v) {
6400
6882
  return typeof v === "string" && v !== "" ? v : void 0;
6401
6883
  }
6884
+ function isWithin(parent, child) {
6885
+ const relative = path12.relative(parent, child);
6886
+ return relative !== "" && !relative.startsWith("..") && !path12.isAbsolute(relative);
6887
+ }
6888
+ function statementTitle(statement, fallback) {
6889
+ const heading = /^#\s+(.+)$/m.exec(statement)?.[1]?.trim();
6890
+ return heading?.replace(/^Case\s+\d+\s*:\s*/i, "") || fallback;
6891
+ }
6892
+ async function readStatementTitle(readme, fallback) {
6893
+ const handle = await fs13.open(readme, "r");
6894
+ try {
6895
+ const buffer = Buffer.alloc(STATEMENT_TITLE_READ_BYTES);
6896
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
6897
+ return statementTitle(buffer.subarray(0, bytesRead).toString("utf8"), fallback);
6898
+ } finally {
6899
+ await handle.close();
6900
+ }
6901
+ }
6402
6902
  function toRun(v) {
6403
6903
  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);
6904
+ const score = scoreOr(r.score);
6905
+ const cost = nullableCostOr(r.cost);
6906
+ const durationMs = nonNegativeIntegerOr(r.duration_ms);
6408
6907
  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;
6908
+ if (score === void 0 || cost === void 0 || durationMs === void 0 || !sessionId)
6909
+ return null;
6910
+ return { score, cost, durationMs, sessionId };
6420
6911
  }
6421
6912
  function toCase(v) {
6422
6913
  const cr = asRecord2(v);
6423
6914
  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
- ];
6915
+ const score = scoreOr(cr.score);
6916
+ const cost = nullableCostOr(cr.cost);
6917
+ const durationMs = nonNegativeIntegerOr(cr.duration_ms);
6918
+ if (!caseId || score === void 0 || cost === void 0 || durationMs === void 0 || "max_score" in cr || !Array.isArray(cr.runs) || cr.runs.length === 0) {
6919
+ return null;
6920
+ }
6921
+ const parsedRuns = cr.runs.map(toRun);
6922
+ if (parsedRuns.some((run) => run === null)) return null;
6923
+ const runs = parsedRuns;
6440
6924
  return {
6441
6925
  case: caseId,
6442
6926
  score,
6443
- ...cost !== void 0 ? { cost } : {},
6444
- ...durationMs !== void 0 ? { durationMs } : {},
6445
- ...sessionId !== void 0 ? { sessionId } : {},
6927
+ cost,
6928
+ durationMs,
6446
6929
  runs
6447
6930
  };
6448
6931
  }
6449
6932
  function toEvaluation(v) {
6450
6933
  const r = asRecord2(v);
6451
6934
  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) : [];
6935
+ const score = scoreOr(r.score);
6936
+ const cost = nullableCostOr(r.cost);
6937
+ const durationMs = nonNegativeIntegerOr(r.duration_ms);
6455
6938
  const summary = stringOr(r.summary);
6456
6939
  const summaryTitle = stringOr(r.summary_title);
6457
6940
  const modelId = stringOr(r.model_id);
6458
6941
  const provider = stringOr(r.provider);
6459
- const version = numberOr(r.version);
6460
- const cost = numberOr(r.cost);
6461
- const durationMs = numberOr(r.duration_ms);
6942
+ const thinkingLevel = stringOr(r.thinking_level);
6943
+ const version = nonNegativeIntegerOr(r.version);
6944
+ 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) {
6945
+ return null;
6946
+ }
6947
+ const parsedCases = r.cases.map(toCase);
6948
+ if (parsedCases.some((item) => item === null)) return null;
6949
+ const cases = parsedCases;
6462
6950
  return {
6463
6951
  time,
6464
6952
  ...summaryTitle !== void 0 ? { summaryTitle } : {},
6465
6953
  ...summary !== void 0 ? { summary } : {},
6466
- ...modelId !== void 0 ? { modelId } : {},
6467
- ...provider !== void 0 ? { provider } : {},
6954
+ modelId,
6955
+ provider,
6956
+ thinkingLevel,
6468
6957
  score,
6469
- ...version !== void 0 ? { version } : {},
6470
- ...cost !== void 0 ? { cost } : {},
6471
- ...durationMs !== void 0 ? { durationMs } : {},
6958
+ version,
6959
+ cost,
6960
+ durationMs,
6472
6961
  cases
6473
6962
  };
6474
6963
  }
6475
6964
  var BenchmarkService = class {
6476
- constructor(root) {
6965
+ constructor(root, workspaceFiles) {
6477
6966
  this.root = root;
6967
+ this.workspaceFiles = workspaceFiles;
6478
6968
  }
6479
6969
  root;
6970
+ workspaceFiles;
6480
6971
  async list(projectId, agentId) {
6481
6972
  const dir = benchmarksDir(this.root, projectId, agentId);
6482
6973
  let items;
6483
6974
  try {
6484
- const entries = await fs12.readdir(dir, { withFileTypes: true });
6975
+ const entries = await fs13.readdir(dir, { withFileTypes: true });
6485
6976
  items = entries.map((e) => ({ name: e.name, isDir: e.isDirectory() }));
6486
6977
  } catch {
6487
6978
  return { benchmarks: [] };
6488
6979
  }
6489
6980
  const benchmarks = [];
6490
6981
  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));
6982
+ benchmarks.push(await this.readBenchmark(path12.join(dir, item.name), item.name));
6492
6983
  }
6493
6984
  return { benchmarks };
6494
6985
  }
6986
+ async listCases(projectId, agentId, benchmarkId) {
6987
+ const baseDir = benchmarksDir(this.root, projectId, agentId);
6988
+ const benchDir = path12.join(baseDir, benchmarkId);
6989
+ let entries;
6990
+ let realBaseDir;
6991
+ let realBenchDir;
6992
+ try {
6993
+ [entries, realBaseDir, realBenchDir] = await Promise.all([
6994
+ fs13.readdir(benchDir, { withFileTypes: true }),
6995
+ fs13.realpath(baseDir),
6996
+ fs13.realpath(benchDir)
6997
+ ]);
6998
+ } catch {
6999
+ return { cases: [] };
7000
+ }
7001
+ if (!isWithin(realBaseDir, realBenchDir)) return { cases: [] };
7002
+ const cases = [];
7003
+ for (const entry of entries.filter((item) => item.isDirectory() && item.name.startsWith("CASE-")).sort((a, b) => a.name.localeCompare(b.name))) {
7004
+ const fallback = { id: entry.name, title: entry.name };
7005
+ try {
7006
+ const statementDir = await this.caseMaterialRoot(
7007
+ projectId,
7008
+ agentId,
7009
+ benchmarkId,
7010
+ entry.name,
7011
+ "statement"
7012
+ );
7013
+ const realReadme = await fs13.realpath(path12.join(statementDir, "README.md"));
7014
+ if (!isWithin(statementDir, realReadme)) throw new Error("README escapes Statement");
7015
+ cases.push({
7016
+ id: entry.name,
7017
+ title: await readStatementTitle(realReadme, entry.name)
7018
+ });
7019
+ } catch {
7020
+ cases.push(fallback);
7021
+ }
7022
+ }
7023
+ return { cases };
7024
+ }
7025
+ async listCaseFiles(projectId, agentId, benchmarkId, caseId, rel, material) {
7026
+ const materialRoot = await this.caseMaterialRoot(
7027
+ projectId,
7028
+ agentId,
7029
+ benchmarkId,
7030
+ caseId,
7031
+ material
7032
+ );
7033
+ return this.workspaceFiles.list(materialRoot, rel);
7034
+ }
7035
+ async readCaseFile(projectId, agentId, benchmarkId, caseId, rel, material, options) {
7036
+ const materialRoot = await this.caseMaterialRoot(
7037
+ projectId,
7038
+ agentId,
7039
+ benchmarkId,
7040
+ caseId,
7041
+ material
7042
+ );
7043
+ return this.workspaceFiles.read(materialRoot, rel, options);
7044
+ }
7045
+ async caseMaterialRoot(projectId, agentId, benchmarkId, caseId, material) {
7046
+ const benchDir = path12.join(benchmarksDir(this.root, projectId, agentId), benchmarkId);
7047
+ const caseDir = path12.join(benchDir, caseId);
7048
+ const materialRoot = path12.join(caseDir, material);
7049
+ try {
7050
+ const [realBenchDir, realCaseDir, realMaterialRoot] = await Promise.all([
7051
+ fs13.realpath(benchDir),
7052
+ fs13.realpath(caseDir),
7053
+ fs13.realpath(materialRoot)
7054
+ ]);
7055
+ if (!isWithin(realBenchDir, realCaseDir) || path12.dirname(realMaterialRoot) !== realCaseDir || path12.basename(realMaterialRoot) !== material) {
7056
+ throw new Error("Case material is not canonical");
7057
+ }
7058
+ return realMaterialRoot;
7059
+ } catch {
7060
+ throw new HttpError(404, "not_found", `Case ${material} does not exist.`);
7061
+ }
7062
+ }
6495
7063
  async readBenchmark(benchDir, id) {
6496
7064
  let title = id;
6497
7065
  let description;
6498
7066
  let runs;
6499
7067
  try {
6500
7068
  const config2 = asRecord2(
6501
- parseToml3(await fs12.readFile(path11.join(benchDir, "benchmark_config.toml"), "utf8"))
7069
+ parseToml3(await fs13.readFile(path12.join(benchDir, "benchmark_config.toml"), "utf8"))
6502
7070
  );
6503
7071
  if (typeof config2.title === "string" && config2.title !== "") title = config2.title;
6504
7072
  if (typeof config2.description === "string" && config2.description !== "") {
@@ -6513,7 +7081,7 @@ var BenchmarkService = class {
6513
7081
  let evaluations = [];
6514
7082
  try {
6515
7083
  const scoreboard = asRecord2(
6516
- parseYaml2(await fs12.readFile(path11.join(benchDir, "scoreboard.yaml"), "utf8"))
7084
+ parseYaml2(await fs13.readFile(path12.join(benchDir, "scoreboard.yaml"), "utf8"))
6517
7085
  );
6518
7086
  if (Array.isArray(scoreboard.evaluations)) {
6519
7087
  evaluations = scoreboard.evaluations.map(toEvaluation).filter((e) => e !== null);
@@ -6522,8 +7090,8 @@ var BenchmarkService = class {
6522
7090
  }
6523
7091
  let caseCount = 0;
6524
7092
  try {
6525
- const entries = await fs12.readdir(benchDir, { withFileTypes: true });
6526
- caseCount = entries.filter((e) => e.isDirectory()).length;
7093
+ const entries = await fs13.readdir(benchDir, { withFileTypes: true });
7094
+ caseCount = entries.filter((e) => e.isDirectory() && e.name.startsWith("CASE-")).length;
6527
7095
  } catch {
6528
7096
  }
6529
7097
  return {
@@ -6538,9 +7106,9 @@ var BenchmarkService = class {
6538
7106
  };
6539
7107
 
6540
7108
  // src/services/snapshot-service.ts
6541
- import { randomBytes as randomBytes5 } from "crypto";
6542
- import fs13 from "fs/promises";
6543
- import path12 from "path";
7109
+ import { randomBytes as randomBytes6 } from "crypto";
7110
+ import fs14 from "fs/promises";
7111
+ import path13 from "path";
6544
7112
  import * as tar from "tar";
6545
7113
  import { parse as parseYaml3 } from "yaml";
6546
7114
  import {
@@ -6553,7 +7121,7 @@ import {
6553
7121
  } from "@prismshadow/penguin-core";
6554
7122
  var VAULT_BASENAME = ".vault.toml";
6555
7123
  function isVaultEntry(entryPath) {
6556
- return path12.posix.basename(entryPath.replaceAll("\\", "/")) === VAULT_BASENAME;
7124
+ return path13.posix.basename(entryPath.replaceAll("\\", "/")) === VAULT_BASENAME;
6557
7125
  }
6558
7126
  var SnapshotService = class {
6559
7127
  constructor(root) {
@@ -6564,7 +7132,7 @@ var SnapshotService = class {
6564
7132
  async currentVersion(projectId, agentId) {
6565
7133
  let raw;
6566
7134
  try {
6567
- raw = await fs13.readFile(systemConfigPath3(this.root, projectId, agentId), "utf8");
7135
+ raw = await fs14.readFile(systemConfigPath3(this.root, projectId, agentId), "utf8");
6568
7136
  } catch {
6569
7137
  throw new HttpError(404, "agent_not_found", "Agent does not exist.");
6570
7138
  }
@@ -6577,14 +7145,14 @@ var SnapshotService = class {
6577
7145
  async ensureSnapshot(projectId, agentId) {
6578
7146
  const version = await this.currentVersion(projectId, agentId);
6579
7147
  const dir = snapshotsDir(this.root, projectId, agentId);
6580
- const file = path12.join(dir, `v${version}.tar.gz`);
7148
+ const file = path13.join(dir, `v${version}.tar.gz`);
6581
7149
  try {
6582
- await fs13.access(file);
7150
+ await fs14.access(file);
6583
7151
  return { version, file };
6584
7152
  } catch {
6585
7153
  }
6586
- await fs13.mkdir(dir, { recursive: true });
6587
- const tmp = `${file}.tmp-${randomBytes5(4).toString("hex")}`;
7154
+ await fs14.mkdir(dir, { recursive: true });
7155
+ const tmp = `${file}.tmp-${randomBytes6(4).toString("hex")}`;
6588
7156
  await tar.create(
6589
7157
  {
6590
7158
  gzip: true,
@@ -6595,7 +7163,7 @@ var SnapshotService = class {
6595
7163
  },
6596
7164
  ["agent_state"]
6597
7165
  );
6598
- await fs13.rename(tmp, file);
7166
+ await fs14.rename(tmp, file);
6599
7167
  return { version, file };
6600
7168
  }
6601
7169
  /** Export: automatically packs a snapshot first if none exists, returns the info needed for download. */
@@ -6612,13 +7180,13 @@ var SnapshotService = class {
6612
7180
  async importArchive(projectId, agentId, archive, confirm) {
6613
7181
  const current = await this.currentVersion(projectId, agentId);
6614
7182
  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 });
7183
+ const staging = path13.join(base, `.import-${randomBytes6(6).toString("hex")}`);
7184
+ await fs14.mkdir(staging, { recursive: true });
6617
7185
  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 });
7186
+ const archiveFile = path13.join(staging, "archive.tar.gz");
7187
+ await fs14.writeFile(archiveFile, archive);
7188
+ const extractDir = path13.join(staging, "extracted");
7189
+ await fs14.mkdir(extractDir, { recursive: true });
6622
7190
  try {
6623
7191
  await tar.extract({
6624
7192
  file: archiveFile,
@@ -6628,10 +7196,10 @@ var SnapshotService = class {
6628
7196
  } catch {
6629
7197
  throw badRequest("Import failed: not a valid tar.gz snapshot package.");
6630
7198
  }
6631
- const configPath = path12.join(extractDir, "agent_state", "system_config.yaml");
7199
+ const configPath = path13.join(extractDir, "agent_state", "system_config.yaml");
6632
7200
  let parsed;
6633
7201
  try {
6634
- parsed = parseYaml3(await fs13.readFile(configPath, "utf8"));
7202
+ parsed = parseYaml3(await fs14.readFile(configPath, "utf8"));
6635
7203
  } catch {
6636
7204
  throw badRequest("Import failed: the package is missing agent_state/system_config.yaml.");
6637
7205
  }
@@ -6654,37 +7222,37 @@ var SnapshotService = class {
6654
7222
  }
6655
7223
  await this.ensureSnapshot(projectId, agentId);
6656
7224
  const stateDir = agentStateDir2(this.root, projectId, agentId);
6657
- const incomingState = path12.join(extractDir, "agent_state");
7225
+ const incomingState = path13.join(extractDir, "agent_state");
6658
7226
  let vault = null;
6659
7227
  try {
6660
- vault = await fs13.readFile(agentVaultPath(this.root, projectId, agentId));
7228
+ vault = await fs14.readFile(agentVaultPath(this.root, projectId, agentId));
6661
7229
  } catch {
6662
7230
  }
6663
7231
  if (vault !== null) {
6664
- await fs13.writeFile(path12.join(incomingState, VAULT_BASENAME), vault, { mode: 384 });
7232
+ await fs14.writeFile(path13.join(incomingState, VAULT_BASENAME), vault, { mode: 384 });
6665
7233
  }
6666
- const trash = path12.join(staging, "replaced-agent_state");
6667
- await fs13.rename(stateDir, trash);
7234
+ const trash = path13.join(staging, "replaced-agent_state");
7235
+ await fs14.rename(stateDir, trash);
6668
7236
  try {
6669
- await fs13.rename(incomingState, stateDir);
7237
+ await fs14.rename(incomingState, stateDir);
6670
7238
  } catch (err) {
6671
- await fs13.rename(trash, stateDir);
7239
+ await fs14.rename(trash, stateDir);
6672
7240
  throw err;
6673
7241
  }
6674
7242
  return { version: incoming };
6675
7243
  } finally {
6676
- await fs13.rm(staging, { recursive: true, force: true });
7244
+ await fs14.rm(staging, { recursive: true, force: true });
6677
7245
  }
6678
7246
  }
6679
7247
  };
6680
7248
 
6681
7249
  // src/services/project-service.ts
6682
- import fs14 from "fs/promises";
7250
+ import fs15 from "fs/promises";
6683
7251
  import { DEFAULT_PROJECT_ID, projectDir, provisionProjectAgents } from "@prismshadow/penguin-core";
6684
7252
  var ABORT_SETTLE_TIMEOUT_MS = 5e3;
6685
- async function dirExists(path17) {
7253
+ async function dirExists(path18) {
6686
7254
  try {
6687
- const stat = await fs14.stat(path17);
7255
+ const stat = await fs15.stat(path18);
6688
7256
  return stat.isDirectory();
6689
7257
  } catch {
6690
7258
  return false;
@@ -6809,12 +7377,12 @@ var ProjectService = class {
6809
7377
  throw err;
6810
7378
  }
6811
7379
  try {
6812
- await fs14.mkdir(projectDir(this.deps.root, projectId), { recursive: true });
7380
+ await fs15.mkdir(projectDir(this.deps.root, projectId), { recursive: true });
6813
7381
  await this.deps.projectConfig.writeInitialConfig(projectId, displayName);
6814
7382
  await this.provisionBuiltinAgents(projectId);
6815
7383
  } catch (err) {
6816
7384
  this.deps.projects.delete(projectId);
6817
- await fs14.rm(projectDir(this.deps.root, projectId), { recursive: true, force: true }).catch(() => {
7385
+ await fs15.rm(projectDir(this.deps.root, projectId), { recursive: true, force: true }).catch(() => {
6818
7386
  });
6819
7387
  throw err;
6820
7388
  }
@@ -6847,6 +7415,23 @@ var ProjectService = class {
6847
7415
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
6848
7416
  });
6849
7417
  }
7418
+ /**
7419
+ * Rename a Project's display name (owner): the id stays immutable — it names the directory,
7420
+ * the Workspace paths and every stored reference — so only the label in project_config.toml
7421
+ * changes. Returns the refreshed summary so the caller can swap it into its list without a
7422
+ * reload.
7423
+ */
7424
+ async renameProject(userId, projectId, name) {
7425
+ const row = this.requireProjectOwner(userId, projectId);
7426
+ await this.deps.projectConfig.setName(projectId, name);
7427
+ return {
7428
+ projectId,
7429
+ name,
7430
+ role: "owner",
7431
+ ownerUserId: row.ownerUserId,
7432
+ createdAt: row.createdAt
7433
+ };
7434
+ }
6850
7435
  /**
6851
7436
  * Delete a Project (owner): default_project is refused; deleting the user's
6852
7437
  * **last accessible Project** is refused too (deleting it would leave the list
@@ -6895,7 +7480,7 @@ var ProjectService = class {
6895
7480
  this.deps.errors.deleteByProject(projectId);
6896
7481
  this.deps.schedules.deleteByProject(projectId);
6897
7482
  this.deps.goals.deleteByProject(projectId);
6898
- await fs14.rm(projectDir(this.deps.root, projectId), { recursive: true, force: true });
7483
+ await fs15.rm(projectDir(this.deps.root, projectId), { recursive: true, force: true });
6899
7484
  }
6900
7485
  // —— Member authorization ——
6901
7486
  /** Member list: owner (role=owner) + members. */
@@ -6966,7 +7551,7 @@ var ProjectService = class {
6966
7551
  };
6967
7552
 
6968
7553
  // src/services/session-service.ts
6969
- import path13 from "path";
7554
+ import path14 from "path";
6970
7555
  import { open, readdir as readdir2 } from "fs/promises";
6971
7556
  import {
6972
7557
  createAgent as createAgent2,
@@ -7072,11 +7657,19 @@ var SessionService = class {
7072
7657
  return await this.sourceOf(row, hasTrace, traces) ?? "active";
7073
7658
  }
7074
7659
  /**
7075
- * List: DB ∪ Trace directory discovery, sorted by createdAt descending. Optional
7076
- * `paging` returns just that slice (the sidebar pages with limit+1 to detect "has
7077
- * more"); slicing happens before toInfo, so per-request source derivation (lazy
7078
- * Trace-head reads) stays bounded by the page size. Discovery/adoption still scans
7079
- * the whole directory — the union and global ordering need every id.
7660
+ * List, sorted by createdAt descending. The default serves **web sessions straight from
7661
+ * the DB** (`client` web/NULL rows) with no Trace directory scanning — the answer to
7662
+ * many-session sidebar reloads re-walking the filesystem on every request (#139). One
7663
+ * lazy discovery walk still runs for a list call that contains rows this process has not
7664
+ * classified yet (no in-process source entry): it supplies the Trace locations for the
7665
+ * one-time head reads and backfills the `has_trace` cache; once every row is classified,
7666
+ * list calls touch only the DB. `includeCli` widens the list to DB ∪ Trace directory
7667
+ * discovery (adopting unmanaged CLI Traces as `client: "cli"` rows), which inherently
7668
+ * scans — that path is opt-in via the "show CLI sessions" preference.
7669
+ *
7670
+ * Optional `paging` returns just that slice (the sidebar pages with limit+1 to detect
7671
+ * "has more"); slicing happens before toInfo, so per-request source derivation (lazy
7672
+ * Trace-head reads) stays bounded by the page size.
7080
7673
  *
7081
7674
  * `category` filters to one sidebar bucket **before** paging, so offset/limit page
7082
7675
  * within the category. Filtering needs each walked row's category (a possible
@@ -7087,20 +7680,32 @@ var SessionService = class {
7087
7680
  * folders (and a workspace group can know its own share) without loading them.
7088
7681
  */
7089
7682
  async listSessions(projectId, agentId, opts = {}) {
7090
- const { paging, category, withCounts } = opts;
7091
- const traces = await this.discoverTraces(projectId, agentId);
7683
+ const { paging, category, withCounts, includeCli } = opts;
7092
7684
  const rows = new Map(
7093
- this.deps.sessions.listByAgent(projectId, agentId).map((r) => [r.sessionId, r])
7685
+ this.deps.sessions.listByAgent(projectId, agentId, { webOnly: !includeCli }).map((r) => [r.sessionId, r])
7094
7686
  );
7095
- for (const [sessionId, location] of traces) {
7096
- if (rows.has(sessionId)) continue;
7097
- const discovered = await this.adoptTraceSession(projectId, agentId, sessionId, location);
7098
- if (discovered) rows.set(sessionId, discovered);
7687
+ let traces;
7688
+ if (includeCli) {
7689
+ traces = await this.discoverTraces(projectId, agentId);
7690
+ for (const [sessionId, location] of traces) {
7691
+ if (rows.has(sessionId)) continue;
7692
+ const discovered = await this.adoptTraceSession(projectId, agentId, sessionId, location);
7693
+ if (discovered) rows.set(sessionId, discovered);
7694
+ }
7695
+ } else if ([...rows.values()].some((r) => this.deps.sources.get(r.sessionId) === void 0)) {
7696
+ traces = await this.discoverTraces(projectId, agentId);
7697
+ for (const row of rows.values()) {
7698
+ if (!row.hasTrace && traces.has(row.sessionId)) {
7699
+ row.hasTrace = true;
7700
+ this.deps.sessions.markHasTrace(row.sessionId);
7701
+ }
7702
+ }
7099
7703
  }
7100
7704
  const sorted = [...rows.values()].sort(
7101
7705
  (a, b) => b.createdAt.localeCompare(a.createdAt) || b.sessionId.localeCompare(a.sessionId)
7102
7706
  );
7103
- const toPage = (page) => Promise.all(page.map((row) => this.toInfo(row, traces.has(row.sessionId), traces)));
7707
+ const rowHasTrace = (row) => traces ? traces.has(row.sessionId) : row.hasTrace === true;
7708
+ const toPage = (page) => Promise.all(page.map((row) => this.toInfo(row, rowHasTrace(row), traces)));
7104
7709
  if (category === void 0 && !withCounts) {
7105
7710
  return {
7106
7711
  sessions: await toPage(
@@ -7114,7 +7719,7 @@ var SessionService = class {
7114
7719
  const matched = [];
7115
7720
  for (const row of sorted) {
7116
7721
  if (!withCounts && matched.length >= want) break;
7117
- const cat = await this.categoryOf(row, traces.has(row.sessionId), traces);
7722
+ const cat = await this.categoryOf(row, rowHasTrace(row), traces);
7118
7723
  counts[cat] += 1;
7119
7724
  if (withCounts) {
7120
7725
  const ws = workspaceCounts[row.workspace] ??= {
@@ -7148,7 +7753,7 @@ var SessionService = class {
7148
7753
  };
7149
7754
  const dir = tracesDir2(this.deps.root, projectId, agentId);
7150
7755
  for (const dateDir of await listDirsSafe(dir)) {
7151
- for (const file of await listFilesSafe(path13.join(dir, dateDir))) {
7756
+ for (const file of await listFilesSafe(path14.join(dir, dateDir))) {
7152
7757
  const match = TRACE_FILE_RE.exec(file);
7153
7758
  if (match) mark(dateDir, match[1]);
7154
7759
  }
@@ -7234,6 +7839,9 @@ var SessionService = class {
7234
7839
  workspace: session.workspaceDir,
7235
7840
  approvalMode: args.approvalMode ?? "allow-all",
7236
7841
  title: null,
7842
+ // Everything created through this service is the Web App's (schedule runs included);
7843
+ // "cli" is stamped only by Trace adoption. NULL means a legacy row, treated as web.
7844
+ client: "web",
7237
7845
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
7238
7846
  };
7239
7847
  this.deps.sessions.insert(row);
@@ -7264,14 +7872,14 @@ var SessionService = class {
7264
7872
  const dir = tracesDir2(this.deps.root, projectId, agentId);
7265
7873
  const out = /* @__PURE__ */ new Map();
7266
7874
  for (const dateDir of await listDirsSafe(dir)) {
7267
- for (const file of await listFilesSafe(path13.join(dir, dateDir))) {
7875
+ for (const file of await listFilesSafe(path14.join(dir, dateDir))) {
7268
7876
  const match = TRACE_FILE_RE.exec(file);
7269
7877
  if (!match) continue;
7270
7878
  const sessionId = match[1];
7271
7879
  const index = Number(match[2]);
7272
7880
  const cur = out.get(sessionId);
7273
7881
  if (!cur || index < cur.index) {
7274
- out.set(sessionId, { path: path13.join(dir, dateDir, file), index });
7882
+ out.set(sessionId, { path: path14.join(dir, dateDir, file), index });
7275
7883
  }
7276
7884
  }
7277
7885
  }
@@ -7306,6 +7914,10 @@ var SessionService = class {
7306
7914
  // The approval mode for an unmanaged Session (started via the CLI) isn't in the Trace, so it's backfilled with the default value.
7307
7915
  approvalMode: "allow-all",
7308
7916
  title: null,
7917
+ // Adopted = a Trace this server never created, i.e. the CLI's: the default list
7918
+ // (web-only) excludes these rows; the "show CLI sessions" preference includes them.
7919
+ client: "cli",
7920
+ hasTrace: true,
7309
7921
  createdAt: sessionIdCreatedAt(sessionId) ?? meta.timestamp
7310
7922
  };
7311
7923
  this.deps.sessions.insertOrIgnore(row);
@@ -7334,13 +7946,13 @@ async function listFilesSafe(dir) {
7334
7946
  }
7335
7947
 
7336
7948
  // src/services/trace-service.ts
7337
- import fs15 from "fs/promises";
7338
- import path14 from "path";
7949
+ import fs16 from "fs/promises";
7950
+ import path15 from "path";
7339
7951
  import {
7340
7952
  agentsDir as agentsDir3,
7341
7953
  isSessionMeta as isSessionMeta5,
7342
7954
  parseTraceLines as parseTraceLines2,
7343
- parseUserSteeringText,
7955
+ parseUserSteeringText as parseUserSteeringText2,
7344
7956
  readTraceTolerant as readTraceTolerant2,
7345
7957
  tracesDir as tracesDir3
7346
7958
  } from "@prismshadow/penguin-core";
@@ -7357,7 +7969,7 @@ function subagentPointer(msg) {
7357
7969
  }
7358
7970
  async function listDirs(dir) {
7359
7971
  try {
7360
- const entries = await fs15.readdir(dir, { withFileTypes: true });
7972
+ const entries = await fs16.readdir(dir, { withFileTypes: true });
7361
7973
  return entries.filter((e) => e.isDirectory()).map((e) => e.name);
7362
7974
  } catch {
7363
7975
  return [];
@@ -7365,7 +7977,7 @@ async function listDirs(dir) {
7365
7977
  }
7366
7978
  async function listFiles(dir) {
7367
7979
  try {
7368
- const entries = await fs15.readdir(dir, { withFileTypes: true });
7980
+ const entries = await fs16.readdir(dir, { withFileTypes: true });
7369
7981
  return entries.filter((e) => e.isFile()).map((e) => e.name);
7370
7982
  } catch {
7371
7983
  return [];
@@ -7381,10 +7993,10 @@ var TraceService = class {
7381
7993
  const dir = tracesDir3(this.root, projectId, agentId);
7382
7994
  const out = [];
7383
7995
  for (const dateDir of await listDirs(dir)) {
7384
- for (const file of await listFiles(path14.join(dir, dateDir))) {
7996
+ for (const file of await listFiles(path15.join(dir, dateDir))) {
7385
7997
  const match = TRACE_FILE_RE2.exec(file);
7386
7998
  if (!match || match[1] !== sessionId) continue;
7387
- out.push({ path: path14.join(dir, dateDir, file), date: dateDir, index: Number(match[2]) });
7999
+ out.push({ path: path15.join(dir, dateDir, file), date: dateDir, index: Number(match[2]) });
7388
8000
  }
7389
8001
  }
7390
8002
  return out.sort((a, b) => a.index - b.index);
@@ -7393,7 +8005,7 @@ var TraceService = class {
7393
8005
  async deleteSessionTraces(projectId, agentId, sessionId) {
7394
8006
  const files = await this.locateAll(projectId, agentId, sessionId);
7395
8007
  for (const file of files) {
7396
- await fs15.rm(file.path, { force: true });
8008
+ await fs16.rm(file.path, { force: true });
7397
8009
  }
7398
8010
  }
7399
8011
  /**
@@ -7431,7 +8043,7 @@ var TraceService = class {
7431
8043
  for (const agentId of await listDirs(agentsDir3(this.root, projectId))) {
7432
8044
  const dir = tracesDir3(this.root, projectId, agentId);
7433
8045
  for (const dateDir of await listDirs(dir)) {
7434
- for (const file of await listFiles(path14.join(dir, dateDir))) {
8046
+ for (const file of await listFiles(path15.join(dir, dateDir))) {
7435
8047
  const match = TRACE_FILE_RE2.exec(file);
7436
8048
  if (match && !index.has(match[1])) index.set(match[1], agentId);
7437
8049
  }
@@ -7474,7 +8086,7 @@ var TraceService = class {
7474
8086
  const files = await this.locateAll(projectId, agentId, sessionId);
7475
8087
  const out = [];
7476
8088
  for (const file of files) {
7477
- const stat = await fs15.stat(file.path);
8089
+ const stat = await fs16.stat(file.path);
7478
8090
  out.push({
7479
8091
  index: file.index,
7480
8092
  date: file.date,
@@ -7510,6 +8122,7 @@ var TraceService = class {
7510
8122
  let prevSerialTs = null;
7511
8123
  let taskIndex = -1;
7512
8124
  let continuation = false;
8125
+ let steeringImages = false;
7513
8126
  let sawToolCallThisRequest = false;
7514
8127
  let compactionActive = false;
7515
8128
  const taskStats = /* @__PURE__ */ new Map();
@@ -7535,9 +8148,12 @@ var TraceService = class {
7535
8148
  const msg = messages[mi];
7536
8149
  const p = msg.payload;
7537
8150
  const hasOrigin = msg.origin !== void 0 && msg.origin.length > 0;
7538
- const isSteeringText = !hasOrigin && msg.type === "model_msg" && p.type === "text" && p.role === "user" && typeof p.text === "string" && parseUserSteeringText(p.text) !== null;
8151
+ const isSteeringText = !hasOrigin && msg.type === "model_msg" && p.type === "text" && p.role === "user" && typeof p.text === "string" && parseUserSteeringText2(p.text) !== null;
7539
8152
  if (isSteeringText) continuation = true;
7540
- const startsUserTurn = !hasOrigin && !compactionActive && !isSteeringText && msg.type === "model_msg" && (p.type === "text" && p.role === "user" || p.type === "image_url");
8153
+ const isImage = !hasOrigin && msg.type === "model_msg" && p.type === "image_url";
8154
+ if (!hasOrigin && !isSteeringText && !(isImage && steeringImages)) steeringImages = false;
8155
+ if (isSteeringText) steeringImages = true;
8156
+ const startsUserTurn = !hasOrigin && !compactionActive && !isSteeringText && !steeringImages && msg.type === "model_msg" && (p.type === "text" && p.role === "user" || p.type === "image_url");
7541
8157
  const startsCompactionTurn = !hasOrigin && msg.type === "event_msg" && p.type === "compaction_begin";
7542
8158
  if (startsUserTurn || startsCompactionTurn) {
7543
8159
  if (pendingFrom === null) pendingFrom = mi;
@@ -7582,7 +8198,7 @@ var TraceService = class {
7582
8198
  }
7583
8199
  } else if (p.type === "request_end") {
7584
8200
  const status = typeof p.status === "string" ? p.status : void 0;
7585
- const retryable = status === "timeout" || status === "malformed";
8201
+ const retryable = status === "timeout" || status === "malformed" || status === "failed" && !compactionActive;
7586
8202
  if (!hasOrigin) {
7587
8203
  prevSerialTs = null;
7588
8204
  continuation = sawToolCallThisRequest || retryable;
@@ -7733,11 +8349,11 @@ var TraceService = class {
7733
8349
  const out = { dates: [] };
7734
8350
  for (const date of dates) {
7735
8351
  const bySession = /* @__PURE__ */ new Map();
7736
- for (const file of await listFiles(path14.join(dir, date))) {
8352
+ for (const file of await listFiles(path15.join(dir, date))) {
7737
8353
  const match = TRACE_FILE_RE2.exec(file);
7738
8354
  if (!match) continue;
7739
8355
  const sessionId = match[1];
7740
- const stat = await fs15.stat(path14.join(dir, date, file));
8356
+ const stat = await fs16.stat(path15.join(dir, date, file));
7741
8357
  const files = bySession.get(sessionId) ?? [];
7742
8358
  files.push({ index: Number(match[2]), sizeBytes: stat.size });
7743
8359
  bySession.set(sessionId, files);
@@ -7773,7 +8389,7 @@ var TraceService = class {
7773
8389
  /** Raw bytes of the Trace file at the given index (export/download: the file is served verbatim). */
7774
8390
  async readFileRaw(projectId, agentId, sessionId, index) {
7775
8391
  const file = await this.locateByIndex(projectId, agentId, sessionId, index);
7776
- return fs15.readFile(file.path);
8392
+ return fs16.readFile(file.path);
7777
8393
  }
7778
8394
  /**
7779
8395
  * Imports an uploaded Trace file (raw JSONL content). Validates the content itself —
@@ -7812,11 +8428,11 @@ var TraceService = class {
7812
8428
  const ts = Date.parse(first.timestamp);
7813
8429
  const date = formatLocalDate(Number.isNaN(ts) ? /* @__PURE__ */ new Date() : new Date(ts));
7814
8430
  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`);
8431
+ const dir = path15.join(tracesDir3(this.root, projectId, agentId), date);
8432
+ await fs16.mkdir(dir, { recursive: true });
8433
+ const file = path15.join(dir, `${sessionId}_${String(index).padStart(3, "0")}.jsonl`);
7818
8434
  try {
7819
- await fs15.writeFile(file, content.replace(/\n+$/, "") + "\n", {
8435
+ await fs16.writeFile(file, content.replace(/\n+$/, "") + "\n", {
7820
8436
  encoding: "utf8",
7821
8437
  flag: "wx"
7822
8438
  });
@@ -8015,6 +8631,33 @@ var UsageService = class {
8015
8631
  models: this.usage.distinctModels(projectId)
8016
8632
  };
8017
8633
  }
8634
+ /**
8635
+ * One page of the error detail table, newest first. The dashboard's own response already
8636
+ * carries the first page (`errors.recent`); this serves the "show me earlier ones" paging,
8637
+ * where refetching the whole aggregate to move one page would be wasteful. `total` is the
8638
+ * filtered row count, so the caller knows when it has reached the end.
8639
+ *
8640
+ * Takes the same filter the dashboard applies — date + agent, and admin-only visibility of
8641
+ * unattributed errors — so a page never widens what the summary above it counted.
8642
+ *
8643
+ * Paging is by offset into a newest-first table that grows at its head, so errors recorded
8644
+ * between two page requests shift every row down and a page can re-show rows already seen.
8645
+ * Accepted deliberately: this is a diagnostic table read at a moment in time, not a feed to
8646
+ * be walked exhaustively, and keying off the newest row's id would cost a cursor the caller
8647
+ * has no other use for.
8648
+ */
8649
+ queryErrors(projectId, q) {
8650
+ const f = {
8651
+ ...q.agentId !== void 0 ? { agentId: q.agentId } : {},
8652
+ ...q.from !== void 0 ? { from: q.from } : {},
8653
+ ...q.to !== void 0 ? { to: q.to } : {},
8654
+ ...q.includeGlobalErrors === true ? { includeGlobal: true } : {}
8655
+ };
8656
+ return {
8657
+ items: this.errors.recent(projectId, f, q.limit, q.offset),
8658
+ total: this.errors.summary(projectId, f).total
8659
+ };
8660
+ }
8018
8661
  /** Error statistics: summary info (total / unexpected / most common error code) + the last N entries, all filtered by the selected range. */
8019
8662
  foldErrors(projectId, f) {
8020
8663
  const { total, unexpected } = this.errors.summary(projectId, f);
@@ -8167,7 +8810,7 @@ function buildAppDeps(config2, overrides = {}) {
8167
8810
  const traceService = new TraceService(config2.root);
8168
8811
  const workspaceFiles = new WorkspaceFilesService();
8169
8812
  const previewTokens = createPreviewTokenSigner();
8170
- const benchmarks = new BenchmarkService(config2.root);
8813
+ const benchmarks = new BenchmarkService(config2.root, workspaceFiles);
8171
8814
  const snapshots = new SnapshotService(config2.root);
8172
8815
  const usageService = new UsageService(
8173
8816
  usageRepo,
@@ -8308,13 +8951,17 @@ function createApp(deps2) {
8308
8951
  await next();
8309
8952
  });
8310
8953
  }
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
- });
8954
+ app2.use(
8955
+ "/api/*",
8956
+ bodyLimit({
8957
+ maxSize: MAX_BODY_BYTES,
8958
+ // Its default is a bare text/plain 413; throw the App's own error instead so the response
8959
+ // stays the documented `payload_too_large` body that every client already handles.
8960
+ onError: () => {
8961
+ throw new HttpError(413, "payload_too_large", "Request body exceeds the 20MB limit.");
8962
+ }
8963
+ })
8964
+ );
8318
8965
  app2.use("/api/*", jsonOnlyWrites);
8319
8966
  app2.route("/api/auth", authRoutes(deps2));
8320
8967
  const auth = authMiddleware(deps2.authService);
@@ -8340,7 +8987,7 @@ function createApp(deps2) {
8340
8987
  app2.route("/api/projects/:projectId/usage", usageRoutes(deps2));
8341
8988
  app2.route("/api/sessions", sessionsRoutes(deps2));
8342
8989
  app2.route("/preview", previewRoutes(deps2));
8343
- if (fs16.existsSync(deps2.config.webDist)) {
8990
+ if (fs17.existsSync(deps2.config.webDist)) {
8344
8991
  registerStaticRoutes(app2, deps2.config.webDist);
8345
8992
  }
8346
8993
  return app2;
@@ -8376,16 +9023,16 @@ function registerStaticRoutes(app2, webDist) {
8376
9023
  return c.json(errorBody("not_found", "Endpoint does not exist."), 404);
8377
9024
  }
8378
9025
  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");
9026
+ const resolved = path16.resolve(webDist, rel === "" ? "index.html" : rel);
9027
+ const base = path16.resolve(webDist);
9028
+ const target = resolved === base || resolved.startsWith(base + path16.sep) ? resolved : path16.join(base, "index.html");
8382
9029
  let file = target;
8383
9030
  try {
8384
9031
  const stat = await fsp.stat(file);
8385
- if (stat.isDirectory()) file = path15.join(file, "index.html");
9032
+ if (stat.isDirectory()) file = path16.join(file, "index.html");
8386
9033
  await fsp.access(file);
8387
9034
  } catch {
8388
- file = path15.join(base, "index.html");
9035
+ file = path16.join(base, "index.html");
8389
9036
  }
8390
9037
  let content;
8391
9038
  try {
@@ -8393,7 +9040,7 @@ function registerStaticRoutes(app2, webDist) {
8393
9040
  } catch {
8394
9041
  return c.json(errorBody("not_found", "Resource does not exist."), 404);
8395
9042
  }
8396
- const type = CONTENT_TYPES2[path15.extname(file).toLowerCase()] ?? "application/octet-stream";
9043
+ const type = CONTENT_TYPES2[path16.extname(file).toLowerCase()] ?? "application/octet-stream";
8397
9044
  return new Response(new Uint8Array(content), {
8398
9045
  status: 200,
8399
9046
  headers: { "Content-Type": type }
@@ -8402,16 +9049,16 @@ function registerStaticRoutes(app2, webDist) {
8402
9049
  }
8403
9050
 
8404
9051
  // src/config.ts
8405
- import fs17 from "fs";
8406
- import path16 from "path";
9052
+ import fs18 from "fs";
9053
+ import path17 from "path";
8407
9054
  import { fileURLToPath } from "url";
8408
9055
  import { DEFAULT_SERVER_PORT, resolveRoot } from "@prismshadow/penguin-core";
8409
9056
  var DAY_MS = 24 * 60 * 60 * 1e3;
8410
9057
  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");
9058
+ const here = path17.dirname(fileURLToPath(import.meta.url));
9059
+ const bundled = path17.resolve(here, "..", "web-dist");
9060
+ if (fs18.existsSync(bundled)) return bundled;
9061
+ return path17.resolve(here, "..", "..", "web", "dist");
8415
9062
  }
8416
9063
  function normalizePreviewOrigin(raw) {
8417
9064
  if (!raw || raw.trim() === "") return null;
@@ -8436,7 +9083,7 @@ function resolveServerConfig(env = process.env) {
8436
9083
  root,
8437
9084
  host: env.HOST ?? "127.0.0.1",
8438
9085
  port,
8439
- dbPath: env.PENGUIN_WEB_DB ?? path16.join(root, "web.db"),
9086
+ dbPath: env.PENGUIN_WEB_DB ?? path17.join(root, "web.db"),
8440
9087
  webDist: env.PENGUIN_WEB_DIST ?? defaultWebDist(),
8441
9088
  previewOrigin: normalizePreviewOrigin(env.PENGUIN_PREVIEW_ORIGIN),
8442
9089
  authSessionTtlMs: 7 * DAY_MS,