@prismshadow/penguin-server 0.1.2 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -3,10 +3,11 @@ import { config as loadDotenv } from "dotenv";
3
3
  import { serve } from "@hono/node-server";
4
4
 
5
5
  // src/app.ts
6
- import fs16 from "fs";
6
+ import fs17 from "fs";
7
7
  import fsp from "fs/promises";
8
- import path15 from "path";
9
- import { Hono as Hono20 } from "hono";
8
+ import path16 from "path";
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";
@@ -102,6 +103,20 @@ CREATE TABLE IF NOT EXISTS schedule_state ( -- schedule runtime state (files
102
103
  invalid_reason TEXT, -- invalidation reason (e.g. the bound Session was deleted); NULL=healthy
103
104
  PRIMARY KEY (project_id, agent_id, name)
104
105
  );
106
+ CREATE TABLE IF NOT EXISTS goal_state ( -- goal-mode runtime state (GOAL.yaml on disk is the model-facing protocol; this row is what the UI reads)
107
+ id INTEGER PRIMARY KEY AUTOINCREMENT, -- one row per goal run; a Session may run goals repeatedly, latest row wins for display
108
+ session_id TEXT NOT NULL,
109
+ project_id TEXT NOT NULL,
110
+ agent_id TEXT NOT NULL,
111
+ objective TEXT NOT NULL,
112
+ status TEXT NOT NULL, -- active | complete | blocked | budget_limited | aborted (aborted is server-side only; on-disk status stays active)
113
+ budget INTEGER NOT NULL, -- token budget; -1 = unlimited
114
+ used INTEGER NOT NULL DEFAULT 0, -- uncached input + output, refreshed per round (mirrors the runner's accounting)
115
+ rounds INTEGER NOT NULL DEFAULT 0,
116
+ created_at TEXT NOT NULL,
117
+ updated_at TEXT NOT NULL
118
+ );
119
+ CREATE INDEX IF NOT EXISTS idx_goal_session ON goal_state(session_id);
105
120
  CREATE TABLE IF NOT EXISTS ui_prefs (
106
121
  user_id TEXT PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
107
122
  prefs_json TEXT NOT NULL -- {theme?, lastProjectId?, ...} free-form JSON
@@ -285,13 +300,13 @@ var ErrorsRepo = class {
285
300
  };
286
301
  }
287
302
  /** The most recent `limit` entries (reverse chronological order). */
288
- recent(projectId, f = {}, limit = 20) {
303
+ recent(projectId, f = {}, limit = 20, offset = 0) {
289
304
  const { where, params } = this.conds(projectId, f);
290
305
  const rows = this.db.prepare(
291
306
  `SELECT ts, source, code, kind, message
292
307
  FROM error_records WHERE ${where}
293
- ORDER BY id DESC LIMIT :limit`
294
- ).all({ ...params, limit });
308
+ ORDER BY id DESC LIMIT :limit OFFSET :offset`
309
+ ).all({ ...params, limit, offset });
295
310
  return rows.map((r) => ({
296
311
  ts: r.ts,
297
312
  source: r.source,
@@ -300,6 +315,10 @@ var ErrorsRepo = class {
300
315
  message: r.message
301
316
  }));
302
317
  }
318
+ /** Cascading cleanup on Agent deletion (unattributed errors carry no agent_id and are unaffected). */
319
+ deleteByAgent(projectId, agentId) {
320
+ this.db.prepare("DELETE FROM error_records WHERE project_id = ? AND agent_id = ?").run(projectId, agentId);
321
+ }
303
322
  /** Cascading cleanup on Project deletion (unattributed errors belong to no Project and are unaffected). */
304
323
  deleteByProject(projectId) {
305
324
  this.db.prepare("DELETE FROM error_records WHERE project_id = ?").run(projectId);
@@ -385,8 +404,77 @@ var ProjectsRepo = class {
385
404
  }
386
405
  };
387
406
 
388
- // src/db/repos/schedules.ts
407
+ // src/db/repos/goals.ts
389
408
  function mapRow2(r) {
409
+ return {
410
+ id: Number(r.id),
411
+ sessionId: r.session_id,
412
+ projectId: r.project_id,
413
+ agentId: r.agent_id,
414
+ objective: r.objective,
415
+ status: r.status,
416
+ budget: Number(r.budget),
417
+ used: Number(r.used),
418
+ rounds: Number(r.rounds),
419
+ createdAt: r.created_at,
420
+ updatedAt: r.updated_at
421
+ };
422
+ }
423
+ var GoalsRepo = class {
424
+ constructor(db) {
425
+ this.db = db;
426
+ }
427
+ db;
428
+ /** Register a new goal run (status active, counters at zero); returns the new row id. */
429
+ create(args) {
430
+ const now = (/* @__PURE__ */ new Date()).toISOString();
431
+ const result = this.db.prepare(
432
+ `INSERT INTO goal_state
433
+ (session_id, project_id, agent_id, objective, status, budget, used, rounds, created_at, updated_at)
434
+ VALUES (?, ?, ?, ?, 'active', ?, 0, 0, ?, ?)`
435
+ ).run(args.sessionId, args.projectId, args.agentId, args.objective, args.budget, now, now);
436
+ return Number(result.lastInsertRowid);
437
+ }
438
+ /** Per-round progress refresh (round just started; `used` mirrors the runner's accounting so far). */
439
+ progress(id, rounds, used) {
440
+ this.db.prepare("UPDATE goal_state SET rounds = ?, used = ?, updated_at = ? WHERE id = ?").run(rounds, used, (/* @__PURE__ */ new Date()).toISOString(), id);
441
+ }
442
+ /** Record the terminal outcome. */
443
+ finish(id, status, rounds, used) {
444
+ this.db.prepare(
445
+ "UPDATE goal_state SET status = ?, rounds = ?, used = ?, updated_at = ? WHERE id = ?"
446
+ ).run(status, rounds, used, (/* @__PURE__ */ new Date()).toISOString(), id);
447
+ }
448
+ /** The Session's most recent goal run (what the chat page's banner restores from); null if it never ran one. */
449
+ latestForSession(sessionId) {
450
+ const r = this.db.prepare("SELECT * FROM goal_state WHERE session_id = ? ORDER BY id DESC LIMIT 1").get(sessionId);
451
+ return r ? mapRow2(r) : null;
452
+ }
453
+ deleteBySession(sessionId) {
454
+ this.db.prepare("DELETE FROM goal_state WHERE session_id = ?").run(sessionId);
455
+ }
456
+ deleteByAgent(projectId, agentId) {
457
+ this.db.prepare("DELETE FROM goal_state WHERE project_id = ? AND agent_id = ?").run(projectId, agentId);
458
+ }
459
+ deleteByProject(projectId) {
460
+ this.db.prepare("DELETE FROM goal_state WHERE project_id = ?").run(projectId);
461
+ }
462
+ /**
463
+ * Startup reconciliation: a goal runs only in SessionManager memory, so a hard crash
464
+ * (SIGKILL, power loss) leaves its row stuck `active` with no runner behind it — the banner
465
+ * would then restore a forever-"running" goal. Called once at boot, before the server accepts
466
+ * connections (nothing can be running yet), so every remaining `active` row is an orphan:
467
+ * mark them `aborted`. The on-disk GOAL.yaml is left `active` as the documented resume point.
468
+ * Returns the number of rows reconciled.
469
+ */
470
+ abortOrphanedActive() {
471
+ const result = this.db.prepare("UPDATE goal_state SET status = 'aborted', updated_at = ? WHERE status = 'active'").run((/* @__PURE__ */ new Date()).toISOString());
472
+ return Number(result.changes);
473
+ }
474
+ };
475
+
476
+ // src/db/repos/schedules.ts
477
+ function mapRow3(r) {
390
478
  return {
391
479
  projectId: r.project_id,
392
480
  agentId: r.agent_id,
@@ -408,11 +496,11 @@ var SchedulesRepo = class {
408
496
  db;
409
497
  find(projectId, agentId, name) {
410
498
  const r = this.db.prepare("SELECT * FROM schedule_state WHERE project_id = ? AND agent_id = ? AND name = ?").get(projectId, agentId, name);
411
- return r ? mapRow2(r) : null;
499
+ return r ? mapRow3(r) : null;
412
500
  }
413
501
  listByAgent(projectId, agentId) {
414
502
  const rows = this.db.prepare("SELECT * FROM schedule_state WHERE project_id = ? AND agent_id = ? ORDER BY name").all(projectId, agentId);
415
- return rows.map((r) => mapRow2(r));
503
+ return rows.map((r) => mapRow3(r));
416
504
  }
417
505
  /**
418
506
  * Register or sync a task's runtime state, returning the synced row plus a `fresh`
@@ -507,13 +595,16 @@ var SchedulesRepo = class {
507
595
  }
508
596
  return removed;
509
597
  }
598
+ deleteByAgent(projectId, agentId) {
599
+ this.db.prepare("DELETE FROM schedule_state WHERE project_id = ? AND agent_id = ?").run(projectId, agentId);
600
+ }
510
601
  deleteByProject(projectId) {
511
602
  this.db.prepare("DELETE FROM schedule_state WHERE project_id = ?").run(projectId);
512
603
  }
513
604
  };
514
605
 
515
606
  // src/db/repos/sessions.ts
516
- function mapRow3(r) {
607
+ function mapRow4(r) {
517
608
  return {
518
609
  sessionId: r.session_id,
519
610
  projectId: r.project_id,
@@ -567,15 +658,15 @@ var SessionsRepo = class {
567
658
  }
568
659
  findById(sessionId) {
569
660
  const r = this.db.prepare("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
570
- return r ? mapRow3(r) : null;
661
+ return r ? mapRow4(r) : null;
571
662
  }
572
663
  listByAgent(projectId, agentId) {
573
664
  const rows = this.db.prepare("SELECT * FROM sessions WHERE project_id = ? AND agent_id = ?").all(projectId, agentId);
574
- return rows.map(mapRow3);
665
+ return rows.map(mapRow4);
575
666
  }
576
667
  listByProject(projectId) {
577
668
  const rows = this.db.prepare("SELECT * FROM sessions WHERE project_id = ?").all(projectId);
578
- return rows.map(mapRow3);
669
+ return rows.map(mapRow4);
579
670
  }
580
671
  updateApprovalMode(sessionId, mode) {
581
672
  this.db.prepare("UPDATE sessions SET approval_mode = ? WHERE session_id = ?").run(mode, sessionId);
@@ -780,7 +871,7 @@ var UsageRepo = class {
780
871
  };
781
872
 
782
873
  // src/db/repos/users.ts
783
- function mapRow4(r) {
874
+ function mapRow5(r) {
784
875
  return {
785
876
  userId: r.user_id,
786
877
  passwordHash: r.password_hash,
@@ -807,12 +898,12 @@ var UsersRepo = class {
807
898
  }
808
899
  findById(userId) {
809
900
  const r = this.db.prepare("SELECT * FROM users WHERE user_id = ?").get(userId);
810
- return r ? mapRow4(r) : null;
901
+ return r ? mapRow5(r) : null;
811
902
  }
812
903
  /** All users (for the admin user backend), ordered by creation time ascending. */
813
904
  list() {
814
905
  const rows = this.db.prepare("SELECT * FROM users ORDER BY created_at ASC, user_id ASC").all();
815
- return rows.map(mapRow4);
906
+ return rows.map(mapRow5);
816
907
  }
817
908
  count() {
818
909
  const r = this.db.prepare("SELECT COUNT(*) AS n FROM users").get();
@@ -1473,6 +1564,14 @@ function projectsRoutes(deps2) {
1473
1564
  const project = await deps2.projectService.createProject(c.var.user, projectId, name);
1474
1565
  return c.json({ project }, 201);
1475
1566
  });
1567
+ app2.patch("/:projectId", async (c) => {
1568
+ const projectId = requireValidId(c, "projectId");
1569
+ const body = await readJson(c);
1570
+ const name = requireString(body, "name", { maxLen: 100, label: "name" }).trim();
1571
+ if (name === "") throw badRequest("name must be at least 1 characters.");
1572
+ const project = await deps2.projectService.renameProject(c.var.user.userId, projectId, name);
1573
+ return c.json({ project });
1574
+ });
1476
1575
  app2.delete("/:projectId", async (c) => {
1477
1576
  await deps2.projectService.deleteProject(c.var.user.userId, requireValidId(c, "projectId"));
1478
1577
  return c.body(null, 204);
@@ -1514,6 +1613,12 @@ function membersRoutes(deps2) {
1514
1613
 
1515
1614
  // src/http/routes/models.ts
1516
1615
  import { Hono as Hono7 } from "hono";
1616
+ function publishCredentialsUpdated(deps2, projectId) {
1617
+ const event = { type: "credentials_updated" };
1618
+ for (const row of deps2.sessionsRepo.listByProject(projectId)) {
1619
+ deps2.channels.peek(row.sessionId)?.publish(event, "server_event");
1620
+ }
1621
+ }
1517
1622
  function parseRef(value, label) {
1518
1623
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
1519
1624
  throw badRequest(`${label} must be a { provider, modelId } object.`);
@@ -1633,7 +1738,10 @@ function modelsRoutes(deps2) {
1633
1738
  const projectId = requireValidId(c, "projectId");
1634
1739
  deps2.projectService.requireProjectOwner(c.var.user.userId, projectId);
1635
1740
  const req = parseModelsUpdate(await readJson(c));
1636
- return c.json(await deps2.projectConfigService.updateModels(projectId, req));
1741
+ const res = await deps2.projectConfigService.updateModels(projectId, req);
1742
+ deps2.manager.invalidateProjectRuntimes(projectId);
1743
+ publishCredentialsUpdated(deps2, projectId);
1744
+ return c.json(res);
1637
1745
  });
1638
1746
  app2.post("/test", async (c) => {
1639
1747
  const projectId = requireValidId(c, "projectId");
@@ -2091,6 +2199,7 @@ async function readItem(deps2, projectId, agentId, name) {
2091
2199
 
2092
2200
  // src/http/routes/benchmarks.ts
2093
2201
  import { Hono as Hono10 } from "hono";
2202
+ var TEXT_PREVIEW_BYTES = 256 * 1024;
2094
2203
  function benchmarksRoutes(deps2) {
2095
2204
  const app2 = new Hono10();
2096
2205
  app2.get("/", async (c) => {
@@ -2100,6 +2209,58 @@ function benchmarksRoutes(deps2) {
2100
2209
  await deps2.agentConfigService.requireExists(projectId, agentId);
2101
2210
  return c.json(await deps2.benchmarks.list(projectId, agentId));
2102
2211
  });
2212
+ app2.get("/:benchmarkId/cases", async (c) => {
2213
+ const projectId = requireValidId(c, "projectId");
2214
+ const agentId = requireValidId(c, "agentId");
2215
+ const benchmarkId = requireValidId(c, "benchmarkId");
2216
+ deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
2217
+ await deps2.agentConfigService.requireExists(projectId, agentId);
2218
+ return c.json(await deps2.benchmarks.listCases(projectId, agentId, benchmarkId));
2219
+ });
2220
+ app2.get("/:benchmarkId/cases/:caseId/files", async (c) => {
2221
+ const projectId = requireValidId(c, "projectId");
2222
+ const agentId = requireValidId(c, "agentId");
2223
+ const benchmarkId = requireValidId(c, "benchmarkId");
2224
+ const caseId = requireValidId(c, "caseId");
2225
+ deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
2226
+ await deps2.agentConfigService.requireExists(projectId, agentId);
2227
+ return c.json(
2228
+ await deps2.benchmarks.listCaseFiles(
2229
+ projectId,
2230
+ agentId,
2231
+ benchmarkId,
2232
+ caseId,
2233
+ c.req.query("path") ?? ""
2234
+ )
2235
+ );
2236
+ });
2237
+ app2.get("/:benchmarkId/cases/:caseId/files/content", async (c) => {
2238
+ const projectId = requireValidId(c, "projectId");
2239
+ const agentId = requireValidId(c, "agentId");
2240
+ const benchmarkId = requireValidId(c, "benchmarkId");
2241
+ const caseId = requireValidId(c, "caseId");
2242
+ deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
2243
+ await deps2.agentConfigService.requireExists(projectId, agentId);
2244
+ const download = c.req.query("download") === "1";
2245
+ const boundedPreview = !download && c.req.query("preview") === "1";
2246
+ const { data, fileName, contentType, scriptable, truncated } = await deps2.benchmarks.readCaseFile(
2247
+ projectId,
2248
+ agentId,
2249
+ benchmarkId,
2250
+ caseId,
2251
+ c.req.query("path") ?? "",
2252
+ boundedPreview ? { maxBytes: TEXT_PREVIEW_BYTES } : void 0
2253
+ );
2254
+ return new Response(new Uint8Array(data), {
2255
+ status: 200,
2256
+ headers: {
2257
+ "Content-Type": !download && scriptable ? "text/plain; charset=utf-8" : contentType,
2258
+ "Content-Disposition": `${download ? "attachment" : "inline"}; filename*=UTF-8''${encodeURIComponent(fileName)}`,
2259
+ "X-Content-Type-Options": "nosniff",
2260
+ ...truncated ? { "X-Content-Truncated": "1" } : {}
2261
+ }
2262
+ });
2263
+ });
2103
2264
  return app2;
2104
2265
  }
2105
2266
 
@@ -2305,6 +2466,9 @@ function agentsRoutes(deps2) {
2305
2466
  await settleWithin(runnings, 5e3);
2306
2467
  await deps2.agentService.deleteAgent(projectId, agentId);
2307
2468
  deps2.sessionsRepo.deleteByAgent(projectId, agentId);
2469
+ deps2.goalsRepo.deleteByAgent(projectId, agentId);
2470
+ deps2.schedulesRepo.deleteByAgent(projectId, agentId);
2471
+ deps2.errorsRepo.deleteByAgent(projectId, agentId);
2308
2472
  } finally {
2309
2473
  deps2.manager.endAgentDeletion(projectId, agentId);
2310
2474
  }
@@ -2410,6 +2574,7 @@ function agentConfigRoutes(deps2) {
2410
2574
 
2411
2575
  // src/http/routes/agent-traces.ts
2412
2576
  import { Hono as Hono16 } from "hono";
2577
+ var MAX_TRACE_BYTES = 14 * 1024 * 1024;
2413
2578
  function agentTracesRoutes(deps2) {
2414
2579
  const app2 = new Hono16();
2415
2580
  app2.get("/", async (c) => {
@@ -2437,6 +2602,41 @@ function agentTracesRoutes(deps2) {
2437
2602
  const index = positiveIntParam(c, "index");
2438
2603
  return c.json(await deps2.traceService.analyze(projectId, agentId, sessionId, index));
2439
2604
  });
2605
+ app2.get("/:sessionId/:index/download", async (c) => {
2606
+ const projectId = requireValidId(c, "projectId");
2607
+ const agentId = requireValidId(c, "agentId");
2608
+ const sessionId = requireValidId(c, "sessionId");
2609
+ deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
2610
+ const index = positiveIntParam(c, "index");
2611
+ const bytes = await deps2.traceService.readFileRaw(projectId, agentId, sessionId, index);
2612
+ const fileName = `${sessionId}_${String(index).padStart(3, "0")}.jsonl`;
2613
+ return new Response(new Uint8Array(bytes), {
2614
+ headers: {
2615
+ "Content-Type": "application/x-ndjson",
2616
+ "Content-Disposition": `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`,
2617
+ "X-Content-Type-Options": "nosniff"
2618
+ }
2619
+ });
2620
+ });
2621
+ app2.post("/import", async (c) => {
2622
+ const projectId = requireValidId(c, "projectId");
2623
+ const agentId = requireValidId(c, "agentId");
2624
+ deps2.projectService.requireProjectOwner(c.var.user.userId, projectId);
2625
+ await deps2.agentConfigService.requireExists(projectId, agentId);
2626
+ const body = await readJson(c);
2627
+ const dataBase64 = requireString(body, "dataBase64", { minLen: 1, maxLen: 20 * 1024 * 1024 });
2628
+ const bytes = Buffer.from(dataBase64, "base64");
2629
+ if (bytes.byteLength === 0) throw badRequest("Import file is empty.");
2630
+ if (bytes.byteLength > MAX_TRACE_BYTES) {
2631
+ throw badRequest("Import file exceeds the 14MB limit.");
2632
+ }
2633
+ const res = await deps2.traceService.importTraceFile(
2634
+ projectId,
2635
+ agentId,
2636
+ bytes.toString("utf8")
2637
+ );
2638
+ return c.json(res);
2639
+ });
2440
2640
  return app2;
2441
2641
  }
2442
2642
 
@@ -2473,12 +2673,32 @@ function usageRoutes(deps2) {
2473
2673
  })
2474
2674
  );
2475
2675
  });
2676
+ app2.get("/errors", (c) => {
2677
+ const projectId = requireValidId(c, "projectId");
2678
+ deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
2679
+ const { offset, limit } = paginationQuery(c);
2680
+ const from = optionalDateParam(c.req.query("from"), "from");
2681
+ const to = optionalDateParam(c.req.query("to"), "to");
2682
+ const agentId = c.req.query("agentId");
2683
+ return c.json(
2684
+ deps2.usageService.queryErrors(projectId, {
2685
+ offset,
2686
+ limit,
2687
+ // Same admin-only rule as the dashboard: a regular member seeing another tenant's
2688
+ // unattributed errors would be a cross-tenant leak.
2689
+ includeGlobalErrors: c.var.user.isAdmin,
2690
+ ...from !== void 0 ? { from } : {},
2691
+ ...to !== void 0 ? { to } : {},
2692
+ ...agentId !== void 0 && agentId !== "" ? { agentId } : {}
2693
+ })
2694
+ );
2695
+ });
2476
2696
  return app2;
2477
2697
  }
2478
2698
 
2479
2699
  // src/http/routes/sessions.ts
2480
- import fs7 from "fs/promises";
2481
- import path6 from "path";
2700
+ import fs8 from "fs/promises";
2701
+ import path7 from "path";
2482
2702
  import { Hono as Hono18 } from "hono";
2483
2703
  import { imageUrlMessage, scratchpadDir, userText } from "@prismshadow/penguin-core";
2484
2704
 
@@ -2658,9 +2878,14 @@ var WorkspaceFilesService = class {
2658
2878
  );
2659
2879
  return unique.filter((_, i) => exists[i]);
2660
2880
  }
2661
- /** List a directory: dirs come first, each group sorted by name; kind follows the symlink target (consistent with read behavior). */
2881
+ /**
2882
+ * List a directory: dirs come first, each group sorted by name. Entries whose
2883
+ * canonical target leaves the Workspace are omitted, so listing cannot expose
2884
+ * metadata for an out-of-bounds symlink.
2885
+ */
2662
2886
  async list(workspace, rel) {
2663
2887
  const dir = await this.resolveRead(workspace, rel);
2888
+ const realBase = await this.realBase(workspace);
2664
2889
  let dirents;
2665
2890
  try {
2666
2891
  dirents = await fs6.readdir(dir, { withFileTypes: true });
@@ -2673,33 +2898,31 @@ var WorkspaceFilesService = class {
2673
2898
  }
2674
2899
  throw err;
2675
2900
  }
2676
- const entries = await Promise.all(
2901
+ const listed = await Promise.all(
2677
2902
  dirents.map(async (d) => {
2678
- let sizeBytes = 0;
2679
- let mtime = "";
2680
- let isDir = d.isDirectory();
2681
2903
  try {
2682
- const stat = await fs6.stat(path5.join(dir, d.name));
2683
- sizeBytes = stat.size;
2684
- mtime = stat.mtime.toISOString();
2685
- isDir = stat.isDirectory();
2904
+ const canonical = await fs6.realpath(path5.join(dir, d.name));
2905
+ this.assertInside(canonical, realBase);
2906
+ const stat = await fs6.stat(canonical);
2907
+ return {
2908
+ name: d.name,
2909
+ kind: stat.isDirectory() ? "dir" : "file",
2910
+ sizeBytes: stat.size,
2911
+ mtime: stat.mtime.toISOString()
2912
+ };
2686
2913
  } catch {
2914
+ return null;
2687
2915
  }
2688
- return {
2689
- name: d.name,
2690
- kind: isDir ? "dir" : "file",
2691
- sizeBytes,
2692
- mtime
2693
- };
2694
2916
  })
2695
2917
  );
2918
+ const entries = listed.filter((entry) => entry !== null);
2696
2919
  entries.sort(
2697
2920
  (a, b) => a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === "dir" ? -1 : 1
2698
2921
  );
2699
2922
  return { path: rel, entries };
2700
2923
  }
2701
2924
  /** Read a file (preview/download): IO on the canonical path (resolveRead has already eliminated symlink escapes). */
2702
- async read(workspace, rel) {
2925
+ async read(workspace, rel, options) {
2703
2926
  const file = await this.resolveRead(workspace, rel);
2704
2927
  let stat;
2705
2928
  try {
@@ -2708,16 +2931,35 @@ var WorkspaceFilesService = class {
2708
2931
  throw new HttpError(404, "path_not_found", "File does not exist.");
2709
2932
  }
2710
2933
  if (stat.isDirectory()) throw badRequest("path is a directory.");
2711
- if (stat.size > MAX_READ_BYTES) {
2934
+ const maxBytes = options?.maxBytes;
2935
+ if (maxBytes !== void 0 && (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_READ_BYTES)) {
2936
+ throw badRequest("maxBytes must be a positive integer within the read limit.");
2937
+ }
2938
+ if (maxBytes === void 0 && stat.size > MAX_READ_BYTES) {
2712
2939
  throw new HttpError(413, "file_too_large", "File exceeds the 50MB read limit.");
2713
2940
  }
2714
- const data = await fs6.readFile(file);
2941
+ let data;
2942
+ let truncated = false;
2943
+ if (maxBytes !== void 0 && stat.size > maxBytes) {
2944
+ const handle = await fs6.open(file, "r");
2945
+ try {
2946
+ const buffer = Buffer.alloc(maxBytes);
2947
+ const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0);
2948
+ data = buffer.subarray(0, bytesRead);
2949
+ truncated = true;
2950
+ } finally {
2951
+ await handle.close();
2952
+ }
2953
+ } else {
2954
+ data = await fs6.readFile(file);
2955
+ }
2715
2956
  const ext = path5.extname(file).toLowerCase();
2716
2957
  return {
2717
2958
  data,
2718
2959
  fileName: path5.basename(file),
2719
2960
  contentType: CONTENT_TYPES[ext] ?? "application/octet-stream",
2720
- scriptable: ext === ".html" || ext === ".htm" || ext === ".svg"
2961
+ scriptable: ext === ".html" || ext === ".htm" || ext === ".svg",
2962
+ ...truncated ? { truncated: true } : {}
2721
2963
  };
2722
2964
  }
2723
2965
  /**
@@ -2735,6 +2977,10 @@ var WorkspaceFilesService = class {
2735
2977
  }
2736
2978
  const { dir, name } = await this.resolveWriteParent(workspace, rel);
2737
2979
  const file = path5.join(dir, name);
2980
+ if (process.platform === "win32") {
2981
+ const st = await fs6.lstat(file).catch(() => null);
2982
+ if (st?.isSymbolicLink()) throw badRequest("path must not be a symlink.");
2983
+ }
2738
2984
  const flags = fsc.O_WRONLY | fsc.O_CREAT | fsc.O_TRUNC | (fsc.O_NOFOLLOW ?? 0);
2739
2985
  let handle;
2740
2986
  try {
@@ -2755,6 +3001,133 @@ var WorkspaceFilesService = class {
2755
3001
  }
2756
3002
  };
2757
3003
 
3004
+ // src/services/task-attachments.ts
3005
+ import fs7 from "fs/promises";
3006
+ import path6 from "path";
3007
+ import { randomBytes as randomBytes4 } from "crypto";
3008
+ import { appendAttachmentLines, attachedFileLine } from "@prismshadow/penguin-core";
3009
+ var MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
3010
+ var MAX_ATTACHMENT_COUNT = 20;
3011
+ var MAX_TOTAL_ATTACHMENT_BYTES = 12 * 1024 * 1024;
3012
+ var MAX_STEM_BYTES = 80;
3013
+ var WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
3014
+ var INVISIBLE_CHAR = new RegExp("\\p{C}", "u");
3015
+ function unsafeNameChar(ch) {
3016
+ if (/[A-Za-z0-9._-]/.test(ch)) return false;
3017
+ return ch.codePointAt(0) < 128 || INVISIBLE_CHAR.test(ch);
3018
+ }
3019
+ function sanitizeSegment(value) {
3020
+ return Array.from(value, (ch) => unsafeNameChar(ch) ? "-" : ch).join("");
3021
+ }
3022
+ function truncateBytes(value, maxBytes) {
3023
+ if (Buffer.byteLength(value) <= maxBytes) return value;
3024
+ let out = "";
3025
+ for (const ch of value) {
3026
+ if (Buffer.byteLength(out) + Buffer.byteLength(ch) > maxBytes) break;
3027
+ out += ch;
3028
+ }
3029
+ return out;
3030
+ }
3031
+ var MAX_NAME_ATTEMPTS = 16;
3032
+ function parseAttachmentPart(part, index) {
3033
+ const fileName = part.fileName;
3034
+ if (typeof fileName !== "string" || fileName.length === 0 || fileName.includes("/") || fileName.includes("\\") || fileName.includes("..") || fileName.includes("\0")) {
3035
+ throw badRequest(
3036
+ `input[${index}].fileName must be a non-empty file name without path separators or "..".`
3037
+ );
3038
+ }
3039
+ const dataUrl = part.dataUrl;
3040
+ const match = typeof dataUrl === "string" ? /^data:[^,]*;base64,([A-Za-z0-9+/=\s]+)$/.exec(dataUrl) : null;
3041
+ if (!match) {
3042
+ throw badRequest(`input[${index}].dataUrl must be a base64 data: URL of the file's bytes.`);
3043
+ }
3044
+ const bytes = Buffer.from(match[1], "base64");
3045
+ if (bytes.length === 0) {
3046
+ throw badRequest(`input[${index}].dataUrl decodes to an empty file.`);
3047
+ }
3048
+ if (bytes.length > MAX_ATTACHMENT_BYTES) {
3049
+ throw new HttpError(
3050
+ 413,
3051
+ "file_too_large",
3052
+ `Attached file exceeds the ${MAX_ATTACHMENT_BYTES / (1024 * 1024)}MB limit.`
3053
+ );
3054
+ }
3055
+ return { fileName, bytes };
3056
+ }
3057
+ function assertAttachmentBudget(attachments) {
3058
+ if (attachments.length > MAX_ATTACHMENT_COUNT) {
3059
+ throw new HttpError(
3060
+ 413,
3061
+ "too_many_files",
3062
+ `A message may carry at most ${MAX_ATTACHMENT_COUNT} attached files.`
3063
+ );
3064
+ }
3065
+ let total = 0;
3066
+ for (const a of attachments) total += a.bytes.length;
3067
+ if (total > MAX_TOTAL_ATTACHMENT_BYTES) {
3068
+ throw new HttpError(
3069
+ 413,
3070
+ "payload_too_large",
3071
+ `Attached files exceed the ${MAX_TOTAL_ATTACHMENT_BYTES / (1024 * 1024)}MB total limit for one message.`
3072
+ );
3073
+ }
3074
+ }
3075
+ function scratchpadName(fileName) {
3076
+ const ext = sanitizeSegment(path6.extname(fileName));
3077
+ const rawStem = fileName.slice(0, fileName.length - path6.extname(fileName).length);
3078
+ const stem = truncateBytes(sanitizeSegment(rawStem), MAX_STEM_BYTES).replace(/[. ]+$/, "");
3079
+ if (!stem || /^-+$/.test(stem)) return `file${ext}`;
3080
+ return WINDOWS_RESERVED.test(stem) ? `_${stem}${ext}` : `${stem}${ext}`;
3081
+ }
3082
+ async function writeAttachment(dir, attachment) {
3083
+ const base = scratchpadName(attachment.fileName);
3084
+ const ext = path6.extname(base);
3085
+ const stem = base.slice(0, base.length - ext.length);
3086
+ for (let attempt = 0; attempt < MAX_NAME_ATTEMPTS; attempt++) {
3087
+ const name = attempt === 0 ? base : `${stem}-${randomBytes4(3).toString("hex")}${ext}`;
3088
+ const file = path6.join(dir, name);
3089
+ try {
3090
+ await fs7.writeFile(file, attachment.bytes, { flag: "wx" });
3091
+ return file;
3092
+ } catch (err) {
3093
+ if (err.code !== "EEXIST") throw err;
3094
+ }
3095
+ }
3096
+ throw new Error(
3097
+ `failed to allocate a unique attachment file name under ${dir} after ${MAX_NAME_ATTEMPTS} attempts`
3098
+ );
3099
+ }
3100
+ async function openScratchpadDir(root, sessionId) {
3101
+ const dir = path6.join(root, sessionId);
3102
+ await fs7.mkdir(dir, { recursive: true });
3103
+ const canonicalRoot = await fs7.realpath(root);
3104
+ const rel = path6.relative(canonicalRoot, await fs7.realpath(dir));
3105
+ if (rel !== sessionId) {
3106
+ throw new Error(
3107
+ `session scratchpad ${dir} resolves outside the agent scratchpad root; refusing to write attachments`
3108
+ );
3109
+ }
3110
+ return dir;
3111
+ }
3112
+ async function removeAttachments(files) {
3113
+ await Promise.all(files.map((f) => fs7.rm(f, { force: true }).catch(() => {
3114
+ })));
3115
+ }
3116
+ async function attachFilesToInput(messages, attachments, root, sessionId) {
3117
+ if (attachments.length === 0) return { input: messages, written: [] };
3118
+ const dir = await openScratchpadDir(root, sessionId);
3119
+ const written = [];
3120
+ try {
3121
+ for (const attachment of attachments) {
3122
+ written.push(await writeAttachment(dir, attachment));
3123
+ }
3124
+ } catch (err) {
3125
+ await removeAttachments(written);
3126
+ throw err;
3127
+ }
3128
+ return { input: appendAttachmentLines(messages, written.map(attachedFileLine)), written };
3129
+ }
3130
+
2758
3131
  // src/http/routes/sessions.ts
2759
3132
  var SESSION_TITLE_MAX = 120;
2760
3133
  var STAT_MAX_PATHS = 100;
@@ -2772,12 +3145,33 @@ var SESSION_CATEGORIES = [
2772
3145
  "schedule",
2773
3146
  "archived"
2774
3147
  ];
3148
+ var IMAGE_DATA_URL = /^data:[^;,]+;base64,[A-Za-z0-9+/=\s]+$/;
3149
+ function requireImageUrl(url, field) {
3150
+ if (typeof url === "string") {
3151
+ if (url.startsWith("http://") || url.startsWith("https://")) return url;
3152
+ if (IMAGE_DATA_URL.test(url)) return url;
3153
+ }
3154
+ throw badRequest(
3155
+ `${field} must be an http(s) URL or a base64 data: URL (data:<mime>;base64,<bytes>).`
3156
+ );
3157
+ }
3158
+ function resolveScratchpadFile(dir, fileName) {
3159
+ if (!fileName || fileName === "." || fileName === "..") return null;
3160
+ for (const ch of fileName) {
3161
+ const code = ch.codePointAt(0);
3162
+ if (code < 32 || code === 127 || ch === "/" || ch === "\\") return null;
3163
+ }
3164
+ const resolved = path7.resolve(dir, fileName);
3165
+ return path7.dirname(resolved) === path7.resolve(dir) ? resolved : null;
3166
+ }
2775
3167
  function parseTaskInput(body) {
2776
3168
  const input = body.input;
2777
3169
  if (!Array.isArray(input) || input.length === 0) {
2778
3170
  throw badRequest("input must be an array with at least one item.");
2779
3171
  }
2780
- return input.map((item, i) => {
3172
+ const messages = [];
3173
+ const attachments = [];
3174
+ input.forEach((item, i) => {
2781
3175
  if (item === null || typeof item !== "object" || Array.isArray(item)) {
2782
3176
  throw badRequest(`input[${i}] must be an object.`);
2783
3177
  }
@@ -2786,17 +3180,39 @@ function parseTaskInput(body) {
2786
3180
  if (typeof part.text !== "string" || part.text.length === 0) {
2787
3181
  throw badRequest(`input[${i}].text must be a non-empty string.`);
2788
3182
  }
2789
- return userText(part.text);
3183
+ messages.push(userText(part.text));
3184
+ return;
2790
3185
  }
2791
3186
  if (part.type === "image_url") {
2792
- const url = part.imageUrl;
2793
- if (typeof url !== "string" || !(url.startsWith("data:") || url.startsWith("http://") || url.startsWith("https://"))) {
2794
- throw badRequest(`input[${i}].imageUrl only supports data: or http(s) URLs.`);
2795
- }
2796
- return imageUrlMessage(url);
3187
+ messages.push(imageUrlMessage(requireImageUrl(part.imageUrl, `input[${i}].imageUrl`)));
3188
+ return;
2797
3189
  }
2798
- throw badRequest(`input[${i}].type must be one of text / image_url.`);
3190
+ if (part.type === "file") {
3191
+ attachments.push(parseAttachmentPart(part, i));
3192
+ assertAttachmentBudget(attachments);
3193
+ return;
3194
+ }
3195
+ throw badRequest(`input[${i}].type must be one of text / image_url / file.`);
2799
3196
  });
3197
+ return { messages, attachments };
3198
+ }
3199
+ function parseSteerImages(body) {
3200
+ const images = body.images;
3201
+ if (images === void 0) return [];
3202
+ if (!Array.isArray(images)) throw badRequest("images must be an array.");
3203
+ return images.map((url, i) => requireImageUrl(url, `images[${i}]`));
3204
+ }
3205
+ function parseGoalField(body) {
3206
+ const goal = body.goal;
3207
+ if (goal === void 0) return null;
3208
+ if (goal === null || typeof goal !== "object" || Array.isArray(goal)) {
3209
+ throw badRequest("goal must be an object.");
3210
+ }
3211
+ const budget = goal.budget;
3212
+ if (budget !== void 0 && (typeof budget !== "number" || !Number.isInteger(budget) || budget <= 0 && budget !== -1)) {
3213
+ throw badRequest("goal.budget must be a positive integer, or -1 for unlimited.");
3214
+ }
3215
+ return { budget: budget ?? -1 };
2800
3216
  }
2801
3217
  function agentSessionsRoutes(deps2) {
2802
3218
  const app2 = new Hono18();
@@ -2947,11 +3363,12 @@ function sessionsRoutes(deps2) {
2947
3363
  ]);
2948
3364
  }
2949
3365
  await deps2.traceService.deleteSessionTraces(row.projectId, row.agentId, row.sessionId);
2950
- await fs7.rm(
2951
- path6.join(scratchpadDir(deps2.config.root, row.projectId, row.agentId), row.sessionId),
3366
+ await fs8.rm(
3367
+ path7.join(scratchpadDir(deps2.config.root, row.projectId, row.agentId), row.sessionId),
2952
3368
  { recursive: true, force: true }
2953
3369
  );
2954
3370
  deps2.sessionsRepo.deleteById(row.sessionId);
3371
+ deps2.goalsRepo.deleteBySession(row.sessionId);
2955
3372
  deps2.sessionSources.delete(row.sessionId);
2956
3373
  } finally {
2957
3374
  deps2.manager.endSessionDeletion(row.sessionId);
@@ -2961,17 +3378,14 @@ function sessionsRoutes(deps2) {
2961
3378
  app2.get("/:sessionId/scratchpad/:fileName", async (c) => {
2962
3379
  const row = resolveSession(c);
2963
3380
  const fileName = c.req.param("fileName") ?? "";
2964
- if (!/^[A-Za-z0-9._-]+$/.test(fileName) || fileName.includes("..")) {
2965
- throw new HttpError(404, "file_not_found", "File does not exist.");
2966
- }
2967
- const filePath = path6.join(
2968
- scratchpadDir(deps2.config.root, row.projectId, row.agentId),
2969
- row.sessionId,
3381
+ const filePath = resolveScratchpadFile(
3382
+ path7.join(scratchpadDir(deps2.config.root, row.projectId, row.agentId), row.sessionId),
2970
3383
  fileName
2971
3384
  );
3385
+ if (!filePath) throw new HttpError(404, "file_not_found", "File does not exist.");
2972
3386
  let bytes;
2973
3387
  try {
2974
- bytes = await fs7.readFile(filePath);
3388
+ bytes = await fs8.readFile(filePath);
2975
3389
  } catch {
2976
3390
  throw new HttpError(404, "file_not_found", "File does not exist.");
2977
3391
  }
@@ -2982,20 +3396,39 @@ function sessionsRoutes(deps2) {
2982
3396
  ".gif": "image/gif",
2983
3397
  ".webp": "image/webp"
2984
3398
  };
2985
- const mime = MIME_BY_EXT[path6.extname(fileName).toLowerCase()] ?? "application/octet-stream";
3399
+ const mime = MIME_BY_EXT[path7.extname(fileName).toLowerCase()];
2986
3400
  return c.body(new Uint8Array(bytes), 200, {
2987
- "content-type": mime,
3401
+ "content-type": mime ?? "application/octet-stream",
3402
+ // nosniff: the composer's file attachments land in this same directory, so the bytes
3403
+ // here are arbitrary user content served from the App's own origin — without it a
3404
+ // browser could sniff an `application/octet-stream` upload back into HTML and run it
3405
+ // same-origin (the same defense workspace file reads apply).
3406
+ "x-content-type-options": "nosniff",
3407
+ // Second, independent layer for everything that fell off the allowlist: the only reason
3408
+ // this endpoint is fetched inline is the conversation's <img> tags, so anything that is
3409
+ // not one of those images is served as a download and never renders as a document —
3410
+ // nosniff alone would be the whole defense otherwise.
3411
+ ...mime === void 0 ? {
3412
+ "content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`
3413
+ } : {},
2988
3414
  "cache-control": "private, max-age=31536000, immutable"
2989
3415
  });
2990
3416
  });
2991
3417
  app2.get("/:sessionId/messages", async (c) => {
2992
3418
  const row = resolveSession(c);
3419
+ let live;
3420
+ if (deps2.manager.statusOf(row.sessionId) !== "idle") {
3421
+ live = {
3422
+ cursor: deps2.channels.get(row.sessionId).lastEventId,
3423
+ fragments: deps2.manager.liveFragments(row.sessionId)
3424
+ };
3425
+ }
2993
3426
  const messages = await deps2.traceService.readMessages(
2994
3427
  row.projectId,
2995
3428
  row.agentId,
2996
3429
  row.sessionId
2997
3430
  );
2998
- return c.json({ messages });
3431
+ return c.json({ messages, ...live !== void 0 ? { live } : {} });
2999
3432
  });
3000
3433
  app2.get("/:sessionId/stream", (c) => {
3001
3434
  const row = resolveSession(c);
@@ -3017,23 +3450,72 @@ function sessionsRoutes(deps2) {
3017
3450
  app2.post("/:sessionId/tasks", async (c) => {
3018
3451
  const row = resolveSession(c);
3019
3452
  const body = await readJson(c);
3020
- const input = parseTaskInput(body);
3453
+ const goal = parseGoalField(body);
3021
3454
  const thinkingLevel = optionalEnum(body, "thinkingLevel", THINKING_LEVELS);
3455
+ if (goal) {
3456
+ const { messages, attachments } = parseTaskInput(body);
3457
+ const text = messages.filter((m) => m.payload.type === "text").map((m) => m.payload.text).join("\n").trim();
3458
+ if (!text) {
3459
+ throw badRequest("goal mode requires a non-empty text objective.");
3460
+ }
3461
+ if (attachments.length > 0) {
3462
+ throw badRequest("goal mode accepts text and images only (no file attachments).");
3463
+ }
3464
+ const { sessionId } = await deps2.manager.startGoal(row.sessionId, {
3465
+ input: messages,
3466
+ budget: goal.budget,
3467
+ ...thinkingLevel !== void 0 ? { thinkingLevel } : {}
3468
+ });
3469
+ return c.json({ sessionId }, 202);
3470
+ }
3471
+ const parsed = parseTaskInput(body);
3022
3472
  const queueIfBusy = body.queueIfBusy === true;
3023
- const { sessionId, queued } = await deps2.manager.startTask(row.sessionId, input, {
3024
- ...thinkingLevel !== void 0 ? { thinkingLevel } : {},
3025
- queueIfBusy
3026
- });
3027
- return c.json({ sessionId, queued }, 202);
3473
+ deps2.manager.assertCanAcceptTask(row.sessionId, { queueIfBusy });
3474
+ const { input, written } = await attachFilesToInput(
3475
+ parsed.messages,
3476
+ parsed.attachments,
3477
+ scratchpadDir(deps2.config.root, row.projectId, row.agentId),
3478
+ row.sessionId
3479
+ );
3480
+ try {
3481
+ const { sessionId, queued } = await deps2.manager.startTask(row.sessionId, input, {
3482
+ ...thinkingLevel !== void 0 ? { thinkingLevel } : {},
3483
+ queueIfBusy
3484
+ });
3485
+ return c.json({ sessionId, queued }, 202);
3486
+ } catch (err) {
3487
+ await removeAttachments(written);
3488
+ throw err;
3489
+ }
3028
3490
  });
3029
3491
  app2.post("/:sessionId/steer", async (c) => {
3030
3492
  const row = resolveSession(c);
3031
3493
  const body = await readJson(c);
3032
3494
  const text = typeof body.text === "string" ? body.text.trim() : "";
3033
- if (!text) throw badRequest("text must be a non-empty string.");
3034
- deps2.manager.steer(row.sessionId, text);
3495
+ const images = parseSteerImages(body);
3496
+ if (!text && images.length === 0) {
3497
+ throw badRequest("text or images must carry the steering message.");
3498
+ }
3499
+ deps2.manager.steer(row.sessionId, [
3500
+ ...text ? [userText(text)] : [],
3501
+ ...images.map((url) => imageUrlMessage(url))
3502
+ ]);
3035
3503
  return c.body(null, 202);
3036
3504
  });
3505
+ app2.get("/:sessionId/goal", (c) => {
3506
+ const row = resolveSession(c);
3507
+ const g = deps2.goalsRepo.latestForSession(row.sessionId);
3508
+ return c.json({
3509
+ goal: g ? {
3510
+ objective: g.objective,
3511
+ status: g.status,
3512
+ budget: g.budget,
3513
+ used: g.used,
3514
+ rounds: g.rounds,
3515
+ updatedAt: g.updatedAt
3516
+ } : null
3517
+ });
3518
+ });
3037
3519
  app2.post("/:sessionId/approvals/:toolCallId", async (c) => {
3038
3520
  const row = resolveSession(c);
3039
3521
  const body = await readJson(c);
@@ -3053,6 +3535,11 @@ function sessionsRoutes(deps2) {
3053
3535
  const aborted = deps2.manager.abortTask(row.sessionId);
3054
3536
  return c.body(null, aborted ? 202 : 204);
3055
3537
  });
3538
+ app2.post("/:sessionId/retry-now", (c) => {
3539
+ const row = resolveSession(c);
3540
+ const skipped = deps2.manager.retryNow(row.sessionId);
3541
+ return c.json({ skipped });
3542
+ });
3056
3543
  app2.post("/:sessionId/compact", async (c) => {
3057
3544
  const row = resolveSession(c);
3058
3545
  const { sessionId } = await deps2.manager.startCompact(row.sessionId);
@@ -3167,6 +3654,104 @@ function sessionsRoutes(deps2) {
3167
3654
  return app2;
3168
3655
  }
3169
3656
 
3657
+ // src/http/routes/version.ts
3658
+ import { spawn } from "child_process";
3659
+ import { BUILD_DATE, VERSION } from "@prismshadow/penguin-core";
3660
+ import { Hono as Hono19 } from "hono";
3661
+ var OUTPUT_CAP = 64 * 1024;
3662
+ var UPDATE_TIMEOUT_MS = 10 * 60 * 1e3;
3663
+ var REFUSAL_MARKERS = [
3664
+ "runs from a source checkout",
3665
+ // sourceCheckout
3666
+ "Cannot tell how this penguin was installed",
3667
+ // unknownInstall
3668
+ "could not be identified",
3669
+ // npmUnknownManager
3670
+ "does not run on Windows",
3671
+ // windowsUnsupported
3672
+ "cannot run your package manager for you"
3673
+ // windowsGlobalInstall
3674
+ ];
3675
+ function classifyUpdateRun(exitCode, output) {
3676
+ if (exitCode !== 0) return { status: "failed", output, needsRestart: false };
3677
+ if (REFUSAL_MARKERS.some((marker) => output.includes(marker))) {
3678
+ return { status: "unsupported", output, needsRestart: false };
3679
+ }
3680
+ return { status: "updated", output, needsRestart: true };
3681
+ }
3682
+ function runSelfUpdate(cliEntry) {
3683
+ return new Promise((resolve) => {
3684
+ const child = spawn(process.execPath, [cliEntry, "update", "--yes"], {
3685
+ env: { ...process.env, PENGUIN_LANG: "en" },
3686
+ stdio: ["ignore", "pipe", "pipe"]
3687
+ });
3688
+ let output = "";
3689
+ const append = (chunk) => {
3690
+ output = (output + chunk.toString("utf8")).slice(-OUTPUT_CAP);
3691
+ };
3692
+ child.stdout.on("data", append);
3693
+ child.stderr.on("data", append);
3694
+ let timedOut = false;
3695
+ const timer = setTimeout(() => {
3696
+ timedOut = true;
3697
+ child.kill("SIGTERM");
3698
+ setTimeout(() => child.kill("SIGKILL"), 1e4).unref();
3699
+ }, UPDATE_TIMEOUT_MS);
3700
+ timer.unref();
3701
+ child.on("error", (err) => {
3702
+ clearTimeout(timer);
3703
+ resolve({
3704
+ status: "failed",
3705
+ output: `${output}
3706
+ [failed to run the update command: ${err.message}]`.trim(),
3707
+ needsRestart: false
3708
+ });
3709
+ });
3710
+ child.on("close", (code) => {
3711
+ clearTimeout(timer);
3712
+ if (timedOut) {
3713
+ resolve({
3714
+ status: "failed",
3715
+ output: `${output}
3716
+ [the update run was killed after ${UPDATE_TIMEOUT_MS / 6e4} minutes]`.trim(),
3717
+ needsRestart: false
3718
+ });
3719
+ return;
3720
+ }
3721
+ resolve(classifyUpdateRun(code ?? -1, output.trim()));
3722
+ });
3723
+ });
3724
+ }
3725
+ function versionRoutes(deps2) {
3726
+ const app2 = new Hono19();
3727
+ let inflightRun = null;
3728
+ app2.get("/", (c) => {
3729
+ return c.json({ version: VERSION, buildDate: BUILD_DATE });
3730
+ });
3731
+ app2.get("/update-check", async (c) => {
3732
+ return c.json(await deps2.updateCheck.check(c.req.query("force") === "1"));
3733
+ });
3734
+ app2.post("/update", async (c) => {
3735
+ if (!c.var.user.isAdmin) {
3736
+ throw new HttpError(403, "admin_required", "Only an admin can perform this operation.");
3737
+ }
3738
+ const cliEntry = process.env.PENGUIN_CLI_ENTRY;
3739
+ if (!cliEntry) {
3740
+ return c.json({
3741
+ status: "unsupported",
3742
+ reason: "not_launched_via_cli",
3743
+ output: "",
3744
+ needsRestart: false
3745
+ });
3746
+ }
3747
+ inflightRun ??= runSelfUpdate(cliEntry).finally(() => {
3748
+ inflightRun = null;
3749
+ });
3750
+ return c.json(await inflightRun);
3751
+ });
3752
+ return app2;
3753
+ }
3754
+
3170
3755
  // src/runtime/channel.ts
3171
3756
  import { randomUUID } from "crypto";
3172
3757
  var DEFAULT_MAX_COUNT = 1e4;
@@ -3191,6 +3776,16 @@ var Channel = class {
3191
3776
  get subscriberCount() {
3192
3777
  return this.listeners.size;
3193
3778
  }
3779
+ /**
3780
+ * Id of the most recently assigned event (`<epoch>-<seq>`; seq 0 when none was assigned
3781
+ * yet). Unicast (sendTo) seqs count too — the value is a position marker, not a buffer
3782
+ * lookup key. Used as the live-tail cursor on GET /messages: captured in the same
3783
+ * synchronous tick as the fragment snapshot, it tells the client which buffered events
3784
+ * the snapshot already covers.
3785
+ */
3786
+ get lastEventId() {
3787
+ return `${this.epoch}-${this.nextSeq - 1}`;
3788
+ }
3194
3789
  /** Broadcast an event: number it, buffer it (evicting the oldest), notify all subscribers. */
3195
3790
  publish(data, event) {
3196
3791
  const entry = this.makeEvent(data, event);
@@ -3358,12 +3953,16 @@ var ErrorRecorder = class {
3358
3953
  };
3359
3954
 
3360
3955
  // src/runtime/session-manager.ts
3361
- import fs8 from "fs/promises";
3362
- import path8 from "path";
3956
+ import fs9 from "fs/promises";
3957
+ import path9 from "path";
3363
3958
  import {
3364
3959
  createAgent,
3365
3960
  findLatestTraceFile,
3961
+ goalFinishedOf,
3962
+ goalTokenDelta,
3963
+ isGoalRoundInput,
3366
3964
  isSessionMeta as isSessionMeta2,
3965
+ stripLeadingMarkerBlocks,
3367
3966
  tracesDir
3368
3967
  } from "@prismshadow/penguin-core";
3369
3968
 
@@ -3434,6 +4033,165 @@ function makeApprove(args) {
3434
4033
  };
3435
4034
  }
3436
4035
 
4036
+ // src/runtime/live-tail.ts
4037
+ import { isPartialPayload } from "@prismshadow/penguin-core";
4038
+ var FRAGMENT_CAP = 512 * 1024;
4039
+ var FRAGMENT_CAP_SLACK = 64 * 1024;
4040
+ function partialKindFor(p) {
4041
+ switch (p.type) {
4042
+ case "text":
4043
+ return p.role === "assistant" ? "partial_text" : null;
4044
+ case "thinking":
4045
+ return "partial_thinking";
4046
+ case "tool_call":
4047
+ return "partial_tool_call";
4048
+ case "tool_call_output":
4049
+ return "partial_tool_call_output";
4050
+ default:
4051
+ return null;
4052
+ }
4053
+ }
4054
+ function fragmentKey(origin, kind, toolCallId) {
4055
+ return `${(origin ?? []).join("/")}\0${kind}::${toolCallId}`;
4056
+ }
4057
+ function toolCallIdOf(p) {
4058
+ const id = p.tool_call_id;
4059
+ return typeof id === "string" ? id : "";
4060
+ }
4061
+ function appendDelta(frag, p) {
4062
+ switch (p.type) {
4063
+ case "partial_text":
4064
+ frag.buffer += p.text;
4065
+ break;
4066
+ case "partial_thinking":
4067
+ frag.buffer += p.thinking;
4068
+ break;
4069
+ case "partial_tool_call":
4070
+ frag.buffer += p.arguments;
4071
+ if (p.name) frag.name = p.name;
4072
+ frag.toolCallId = p.tool_call_id;
4073
+ break;
4074
+ case "partial_tool_call_output":
4075
+ frag.buffer += p.output;
4076
+ if (p.images && p.images.length > 0) frag.images = p.images;
4077
+ frag.toolCallId = p.tool_call_id;
4078
+ break;
4079
+ }
4080
+ if (frag.buffer.length > FRAGMENT_CAP + FRAGMENT_CAP_SLACK) {
4081
+ frag.buffer = frag.buffer.slice(frag.buffer.length - FRAGMENT_CAP);
4082
+ }
4083
+ }
4084
+ function startPayload(frag) {
4085
+ switch (frag.kind) {
4086
+ case "partial_text":
4087
+ return { type: "partial_text", role: "assistant", event_type: "start", text: frag.buffer };
4088
+ case "partial_thinking":
4089
+ return {
4090
+ type: "partial_thinking",
4091
+ role: "assistant",
4092
+ event_type: "start",
4093
+ thinking: frag.buffer
4094
+ };
4095
+ case "partial_tool_call":
4096
+ return {
4097
+ type: "partial_tool_call",
4098
+ role: "assistant",
4099
+ event_type: "start",
4100
+ name: frag.name ?? "",
4101
+ arguments: frag.buffer,
4102
+ tool_call_id: frag.toolCallId ?? ""
4103
+ };
4104
+ case "partial_tool_call_output":
4105
+ return {
4106
+ type: "partial_tool_call_output",
4107
+ role: "user",
4108
+ event_type: "start",
4109
+ output: frag.buffer,
4110
+ ...frag.images !== void 0 && frag.images.length > 0 ? { images: frag.images } : {},
4111
+ tool_call_id: frag.toolCallId ?? ""
4112
+ };
4113
+ }
4114
+ }
4115
+ var LiveTailTracker = class {
4116
+ /** sessionId → open fragments keyed by fragmentKey, in the order they were opened. */
4117
+ sessions = /* @__PURE__ */ new Map();
4118
+ /** Feed one published message (call in the same synchronous tick as the channel publish). */
4119
+ observe(sessionId, msg) {
4120
+ if (msg.type !== "model_msg") return;
4121
+ const p = msg.payload;
4122
+ if (!isPartialPayload(p)) {
4123
+ const kind = partialKindFor(p);
4124
+ if (kind === null) return;
4125
+ const open3 = this.sessions.get(sessionId);
4126
+ if (!open3) return;
4127
+ open3.delete(fragmentKey(msg.origin, kind, toolCallIdOf(p)));
4128
+ if (open3.size === 0) this.sessions.delete(sessionId);
4129
+ return;
4130
+ }
4131
+ const key = fragmentKey(msg.origin, p.type, toolCallIdOf(p));
4132
+ let open2 = this.sessions.get(sessionId);
4133
+ if (p.event_type === "start") {
4134
+ if (!open2) {
4135
+ open2 = /* @__PURE__ */ new Map();
4136
+ this.sessions.set(sessionId, open2);
4137
+ }
4138
+ const frag2 = {
4139
+ kind: p.type,
4140
+ timestamp: msg.timestamp,
4141
+ buffer: "",
4142
+ ...msg.origin && msg.origin.length > 0 ? { origin: [...msg.origin] } : {}
4143
+ };
4144
+ open2.delete(key);
4145
+ open2.set(key, frag2);
4146
+ appendDelta(frag2, p);
4147
+ return;
4148
+ }
4149
+ let frag = open2?.get(key);
4150
+ if (!frag) {
4151
+ if (p.event_type === "stop") return;
4152
+ if (!open2) {
4153
+ open2 = /* @__PURE__ */ new Map();
4154
+ this.sessions.set(sessionId, open2);
4155
+ }
4156
+ frag = {
4157
+ kind: p.type,
4158
+ timestamp: msg.timestamp,
4159
+ buffer: "",
4160
+ ...msg.origin && msg.origin.length > 0 ? { origin: [...msg.origin] } : {}
4161
+ };
4162
+ open2.set(key, frag);
4163
+ }
4164
+ appendDelta(frag, p);
4165
+ if (p.event_type === "stop") {
4166
+ open2.delete(key);
4167
+ if (open2.size === 0) this.sessions.delete(sessionId);
4168
+ }
4169
+ }
4170
+ /**
4171
+ * Synthetic `partial_* start` messages for every open fragment (in open order), each
4172
+ * carrying the full accumulated content, the original origin chain, and the original
4173
+ * start timestamp. Empty when the session has no open fragments.
4174
+ */
4175
+ fragments(sessionId) {
4176
+ const open2 = this.sessions.get(sessionId);
4177
+ if (!open2) return [];
4178
+ const out = [];
4179
+ for (const frag of open2.values()) {
4180
+ out.push({
4181
+ timestamp: frag.timestamp,
4182
+ type: "model_msg",
4183
+ payload: startPayload(frag),
4184
+ ...frag.origin !== void 0 ? { origin: [...frag.origin] } : {}
4185
+ });
4186
+ }
4187
+ return out;
4188
+ }
4189
+ /** Drop all fragment state for a session (the run ended; nothing will continue these fragments). */
4190
+ clear(sessionId) {
4191
+ this.sessions.delete(sessionId);
4192
+ }
4193
+ };
4194
+
3437
4195
  // src/runtime/session-sources.ts
3438
4196
  function asSessionSource(v) {
3439
4197
  return v === "schedule" || v === "subagent" ? v : void 0;
@@ -3456,11 +4214,26 @@ var SessionSources = class {
3456
4214
 
3457
4215
  // src/runtime/stream-error-watcher.ts
3458
4216
  import { isEventMessage, isModelMessage, isSessionMeta } from "@prismshadow/penguin-core";
3459
- import path7 from "path";
4217
+ import path8 from "path";
3460
4218
  var TOOL_NAMES_MAX = 1e3;
3461
4219
  var ORIGIN_CTX_MAX = 200;
3462
4220
  var LLM_FAILURES = {
3463
- failed: { code: "llm_failed", kind: "unexpected", text: "LLM request failed (not retryable)." },
4221
+ // Reached here only when no further attempt followed: the ladder ran out (or the run ended
4222
+ // on it), so the user lost the turn and someone should look. A `failed` that was retried
4223
+ // takes LLM_FAILED_RETRIED instead.
4224
+ failed: {
4225
+ code: "llm_failed",
4226
+ kind: "unexpected",
4227
+ text: "LLM request failed and the retries did not recover it."
4228
+ },
4229
+ // Its own code, not the failed bucket: dedup is `(source, code, Project)` over a short
4230
+ // window, and `llm_failed_retried` can now fire on any recovered blip — sharing a bucket
4231
+ // would let a genuine credential failure be dropped as a duplicate of one.
4232
+ auth: {
4233
+ code: "llm_auth",
4234
+ kind: "unexpected",
4235
+ text: "LLM request rejected: the provider did not accept the credentials."
4236
+ },
3464
4237
  timeout: {
3465
4238
  code: "llm_timeout",
3466
4239
  kind: "expected",
@@ -3472,12 +4245,23 @@ var LLM_FAILURES = {
3472
4245
  text: "LLM response could not be parsed (the engine reconnects and retries)."
3473
4246
  }
3474
4247
  };
4248
+ var LLM_FAILED_RETRIED = {
4249
+ code: "llm_failed_retried",
4250
+ kind: "expected",
4251
+ text: "LLM request failed (the engine reconnects and retries)."
4252
+ };
3475
4253
  function isLlmFailure(s) {
3476
- return s === "failed" || s === "timeout" || s === "malformed";
4254
+ return s === "failed" || s === "timeout" || s === "malformed" || s === "auth";
3477
4255
  }
3478
4256
  function isToolFailure(s) {
3479
4257
  return s === "failed" || s === "timeout";
3480
4258
  }
4259
+ var COMMAND_TOOLS = /* @__PURE__ */ new Set(["exec_command", "input_command"]);
4260
+ var ORDINARY_EXIT_NOTE = /\[exit code: [^\]\n]*\]\s*$/;
4261
+ function isOrdinaryCommandExit(name, output) {
4262
+ if (name !== void 0 && !COMMAND_TOOLS.has(name)) return false;
4263
+ return ORDINARY_EXIT_NOTE.test(output);
4264
+ }
3481
4265
  function isUserAbortReason(reason) {
3482
4266
  return /^aborted\b/i.test(reason);
3483
4267
  }
@@ -3497,7 +4281,7 @@ var StreamErrorWatcher = class {
3497
4281
  }
3498
4282
  errors;
3499
4283
  ctx;
3500
- /** LLM failures awaiting a real reason: origin → failure state (see file header; each session has at most one in-flight Request). */
4284
+ /** LLM failures awaiting a real reason: origin → failure state + the request_end's own detail (see file header; each session has at most one in-flight Request). */
3501
4285
  pending = /* @__PURE__ */ new Map();
3502
4286
  /** Names of in-flight tool calls: `origin \0 tool_call_id` → tool name (dequeued once output arrives). */
3503
4287
  toolNames = /* @__PURE__ */ new Map();
@@ -3535,7 +4319,7 @@ var StreamErrorWatcher = class {
3535
4319
  registerOrigin(msg) {
3536
4320
  const key = originKey(msg);
3537
4321
  if (!key) return;
3538
- const agentId = path7.basename(path7.dirname(msg.payload.agent_state));
4322
+ const agentId = path8.basename(path8.dirname(msg.payload.agent_state));
3539
4323
  if (!agentId || agentId === "." || agentId === "..") return;
3540
4324
  this.originCtx.delete(key);
3541
4325
  this.originCtx.set(key, { agentId, sessionId: key });
@@ -3561,11 +4345,18 @@ var StreamErrorWatcher = class {
3561
4345
  const key = originKey(msg);
3562
4346
  if (p.type === "request_end") {
3563
4347
  this.flush(key);
3564
- if (isLlmFailure(p.status)) this.pending.set(key, p.status);
4348
+ if (isLlmFailure(p.status)) {
4349
+ this.pending.set(key, {
4350
+ status: p.status,
4351
+ // The event's own failure detail (LLMOutcome.message): the message of record when
4352
+ // no abort follows (the retry path).
4353
+ ...typeof p.message === "string" && p.message.trim() ? { message: p.message } : {}
4354
+ });
4355
+ }
3565
4356
  return;
3566
4357
  }
3567
4358
  if (p.type === "request_begin") {
3568
- this.flush(key);
4359
+ this.flush(key, null, true);
3569
4360
  return;
3570
4361
  }
3571
4362
  if (p.type === "abort") {
@@ -3574,17 +4365,23 @@ var StreamErrorWatcher = class {
3574
4365
  }
3575
4366
  /**
3576
4367
  * Persist a pending LLM failure (no-op if none is pending); `reason` is the abort
3577
- * message that arrived afterward. Pending state is already bucketed by origin, so
4368
+ * message that arrived afterward, and `retried` says whether another attempt followed
4369
+ * (only a `request_begin` proves that). Pending state is already bucketed by origin, so
3578
4370
  * `key` is exactly "the session that produced this failure" — attribution is looked
3579
4371
  * up from it (see file header).
4372
+ *
4373
+ * `retried` defaults to false so every other resolution — an abort, the defensive flush at
4374
+ * the next request_end, or close() at the end of the run — is read as "nothing came after
4375
+ * this failure". That is the conservative direction: at worst a recovered blip is escalated,
4376
+ * never a lost turn silently downgraded.
3580
4377
  */
3581
- flush(key, reason) {
3582
- const status = this.pending.get(key);
3583
- if (status === void 0) return;
4378
+ flush(key, reason, retried = false) {
4379
+ const entry = this.pending.get(key);
4380
+ if (entry === void 0) return;
3584
4381
  this.pending.delete(key);
3585
- const spec = LLM_FAILURES[status];
4382
+ const spec = retried && entry.status === "failed" ? LLM_FAILED_RETRIED : LLM_FAILURES[entry.status];
3586
4383
  const trimmed = reason?.trim();
3587
- const message = trimmed && !isUserAbortReason(trimmed) ? trimmed : spec.text;
4384
+ const message = trimmed && !isUserAbortReason(trimmed) ? trimmed : entry.message ?? spec.text;
3588
4385
  this.errors.record({
3589
4386
  source: "llm",
3590
4387
  err: message,
@@ -3612,9 +4409,11 @@ var StreamErrorWatcher = class {
3612
4409
  const name = this.toolNames.get(key);
3613
4410
  this.toolNames.delete(key);
3614
4411
  if (!isToolFailure(p.stop_reason)) return;
4412
+ const output = p.output ?? "";
4413
+ if (isOrdinaryCommandExit(name, output)) return;
3615
4414
  this.errors.record({
3616
4415
  source: "environment",
3617
- err: toolFailureText(p.output ?? ""),
4416
+ err: toolFailureText(output),
3618
4417
  ctx: this.ctxFor(origin),
3619
4418
  // The tool name goes into code: so the stats dashboard's "most common error code" and table can show which tool failed.
3620
4419
  code: `tool_${p.stop_reason}:${name ?? "unknown"}`,
@@ -3653,7 +4452,7 @@ function createCoreSessionLoader(root, sources) {
3653
4452
  }
3654
4453
  }
3655
4454
  try {
3656
- const stat = await fs8.stat(row.workspace);
4455
+ const stat = await fs9.stat(row.workspace);
3657
4456
  if (!stat.isDirectory()) throw new Error("not a directory");
3658
4457
  } catch {
3659
4458
  throw new HttpError(
@@ -3748,6 +4547,8 @@ var SessionManager = class {
3748
4547
  deletingSessions = /* @__PURE__ */ new Set();
3749
4548
  /** Per-Agent config generation (key = agentKey), bumped by invalidateAgentRuntimes on vault updates. */
3750
4549
  agentGenerations = /* @__PURE__ */ new Map();
4550
+ /** Open streaming fragments of running sessions (fed by drive, served to GET /messages; see live-tail.ts). */
4551
+ liveTail = new LiveTailTracker();
3751
4552
  sweepTimer;
3752
4553
  // —— Query surface (used by Session listing / Agent active-count / SSE subscription replay) ——
3753
4554
  statusOf(sessionId) {
@@ -3763,6 +4564,15 @@ var SessionManager = class {
3763
4564
  pendingFollowUpCount(sessionId) {
3764
4565
  return this.entries.get(sessionId)?.followUps.length ?? 0;
3765
4566
  }
4567
+ /**
4568
+ * Live tail of a running session: one synthetic `partial_* start` OmniMessage per open
4569
+ * streaming fragment, carrying the full accumulated content so far (see live-tail.ts).
4570
+ * Empty when idle or when nothing is streaming. GET /messages attaches this (with a
4571
+ * channel cursor) so a client joining mid-stream can render the in-progress message.
4572
+ */
4573
+ liveFragments(sessionId) {
4574
+ return this.liveTail.fragments(sessionId);
4575
+ }
3766
4576
  /** Number of Sessions for this Agent that are currently running / compacting. */
3767
4577
  activeCountForAgent(projectId, agentId) {
3768
4578
  let n = 0;
@@ -3802,7 +4612,39 @@ var SessionManager = class {
3802
4612
  const key = agentKey(projectId, agentId);
3803
4613
  this.agentGenerations.set(key, this.generationOf(projectId, agentId) + 1);
3804
4614
  }
4615
+ /**
4616
+ * After a Project's models/credentials change: invalidate every cached runtime in this
4617
+ * Project, so the next Task re-resumes with the new api_key / base_url. Same
4618
+ * effective-value semantics as invalidateAgentRuntimes — no hot swap into a Task already
4619
+ * in flight. Iterating the active table is complete here, not a shortcut: the generation
4620
+ * map only matters for runtimes that are already cached, and an Agent with no cached
4621
+ * entry builds fresh through the loader anyway.
4622
+ */
4623
+ invalidateProjectRuntimes(projectId) {
4624
+ const agentIds = /* @__PURE__ */ new Set();
4625
+ for (const e of this.entries.values()) {
4626
+ if (e.projectId === projectId) agentIds.add(e.agentId);
4627
+ }
4628
+ for (const agentId of agentIds) this.invalidateAgentRuntimes(projectId, agentId);
4629
+ }
3805
4630
  // —— Task / compaction drive ——
4631
+ /**
4632
+ * Cheap, lock-free rehearsal of the 409/503 conditions startTask checks, throwing exactly the
4633
+ * same HttpErrors. **Advisory only**: it neither takes the Session lock nor loads an entry, so
4634
+ * a session that isn't in the active table reads as acceptable and a status change racing this
4635
+ * call is not caught — the authoritative check is still the one inside startTask.
4636
+ *
4637
+ * It exists so a caller that has irreversible work to do first (POST /tasks writes the
4638
+ * message's file attachments to disk) can find out about the ordinary "a Task is already
4639
+ * running" rejection before doing it, instead of undoing it afterwards.
4640
+ */
4641
+ assertCanAcceptTask(sessionId, opts) {
4642
+ this.assertOpen();
4643
+ this.assertAgentNotDeleting(sessionId);
4644
+ this.assertSessionNotDeleting(sessionId);
4645
+ const entry = this.entries.get(sessionId);
4646
+ if (entry && !opts?.queueIfBusy) this.assertIdle(entry);
4647
+ }
3806
4648
  /**
3807
4649
  * Start a Task: get-or-load → 409
3808
4650
  * mutual-exclusion check → publish the input messages first → drive run in the
@@ -3834,6 +4676,135 @@ var SessionManager = class {
3834
4676
  return { sessionId: entry.sessionId, queued: false };
3835
4677
  });
3836
4678
  }
4679
+ /**
4680
+ * Start a goal run: like startTask, but the run is core goal mode — one
4681
+ * `session.run(input, { goal })` call loops rounds until a terminal state, and the
4682
+ * Session stays `running` for the whole goal (every round), so the existing abort
4683
+ * endpoint interrupts the entire loop and schedules queue behind it as usual. Round
4684
+ * inputs are yielded by core and published like any streamed message; progress
4685
+ * additionally goes out as goal_* server events and into goal_state (when a repo is
4686
+ * wired).
4687
+ */
4688
+ async startGoal(sessionId, args) {
4689
+ return this.withLock(sessionId, async () => {
4690
+ this.assertOpen();
4691
+ this.assertAgentNotDeleting(sessionId);
4692
+ this.assertSessionNotDeleting(sessionId);
4693
+ const entry = await this.ensureEntry(sessionId);
4694
+ this.assertIdle(entry);
4695
+ const text = args.input.filter(isPlainText("user")).map((m) => m.payload.text).join("\n");
4696
+ const objective = stripLeadingMarkerBlocks(text).trim() || text.trim();
4697
+ const ac = new AbortController();
4698
+ entry.status = "running";
4699
+ entry.abort = ac;
4700
+ entry.lastActivityMs = Date.now();
4701
+ this.publishState(entry, "running");
4702
+ const approve = makeApprove({
4703
+ getMode: () => this.deps.sessions.findById(entry.sessionId)?.approvalMode ?? "always-ask",
4704
+ toolPermission: (name) => entry.session.toolPermission(name),
4705
+ registry: entry.approvals,
4706
+ publishRequest: (pending) => this.publishEvent(entry, {
4707
+ type: "approval_request",
4708
+ toolCall: pending.toolCall,
4709
+ ...pending.origin !== void 0 ? { origin: pending.origin } : {}
4710
+ })
4711
+ });
4712
+ const goalId = this.deps.goals?.create({
4713
+ sessionId: entry.sessionId,
4714
+ projectId: entry.projectId,
4715
+ agentId: entry.agentId,
4716
+ objective,
4717
+ budget: args.budget
4718
+ });
4719
+ this.publishEvent(entry, {
4720
+ type: "goal_started",
4721
+ sessionId: entry.sessionId,
4722
+ objective,
4723
+ budget: args.budget
4724
+ });
4725
+ const gen = this.goalStream(entry, {
4726
+ input: args.input,
4727
+ budget: args.budget,
4728
+ ...args.thinkingLevel !== void 0 ? { thinkingLevel: args.thinkingLevel } : {},
4729
+ approve,
4730
+ signal: ac.signal,
4731
+ ...goalId !== void 0 ? { goalId } : {}
4732
+ });
4733
+ entry.running = this.drive(entry, gen, { userExcerpt: objective });
4734
+ return { sessionId: entry.sessionId };
4735
+ });
4736
+ }
4737
+ /**
4738
+ * Taps core's goal-mode stream for `drive`: round boundaries (the injected `[goal]`
4739
+ * inputs) become goal_round events + goal_state refreshes, and the terminal
4740
+ * `goal_finished` event message becomes the goal_finished server event + the run-state
4741
+ * row's final status. Token numbers mirror core's own accounting (same
4742
+ * `goalTokenDelta`), so the UI shows exactly what the budget check uses.
4743
+ */
4744
+ async *goalStream(entry, args) {
4745
+ const gen = entry.session.run(args.input, {
4746
+ approve: args.approve,
4747
+ signal: args.signal,
4748
+ ...args.thinkingLevel !== void 0 ? { thinkingLevel: args.thinkingLevel } : {},
4749
+ goal: { budget: args.budget }
4750
+ });
4751
+ let round = 0;
4752
+ let used = 0;
4753
+ let finished = false;
4754
+ try {
4755
+ for await (const msg of gen) {
4756
+ used += goalTokenDelta(msg);
4757
+ if (isGoalRoundInput(msg)) {
4758
+ round++;
4759
+ if (args.goalId !== void 0) this.deps.goals?.progress(args.goalId, round, used);
4760
+ this.publishEvent(entry, {
4761
+ type: "goal_round",
4762
+ sessionId: entry.sessionId,
4763
+ round,
4764
+ used,
4765
+ budget: args.budget
4766
+ });
4767
+ }
4768
+ const outcome = goalFinishedOf(msg);
4769
+ if (outcome) {
4770
+ finished = true;
4771
+ if (args.goalId !== void 0) {
4772
+ this.deps.goals?.finish(
4773
+ args.goalId,
4774
+ outcome.outcome,
4775
+ outcome.rounds,
4776
+ outcome.tokensUsed
4777
+ );
4778
+ }
4779
+ this.publishEvent(entry, {
4780
+ type: "goal_finished",
4781
+ sessionId: entry.sessionId,
4782
+ outcome: outcome.outcome,
4783
+ rounds: outcome.rounds,
4784
+ used: outcome.tokensUsed
4785
+ });
4786
+ }
4787
+ yield msg;
4788
+ }
4789
+ if (!finished) {
4790
+ this.finishAborted(entry, args.goalId, round, used);
4791
+ }
4792
+ } catch (err) {
4793
+ if (!finished) this.finishAborted(entry, args.goalId, round, used);
4794
+ throw err;
4795
+ }
4796
+ }
4797
+ /** Closes a goal's run state as aborted (stream cut off / infrastructure failure). */
4798
+ finishAborted(entry, goalId, round, used) {
4799
+ if (goalId !== void 0) this.deps.goals?.finish(goalId, "aborted", round, used);
4800
+ this.publishEvent(entry, {
4801
+ type: "goal_finished",
4802
+ sessionId: entry.sessionId,
4803
+ outcome: "aborted",
4804
+ rounds: round,
4805
+ used
4806
+ });
4807
+ }
3837
4808
  /** Shared task launch (fresh tasks and auto-started follow-ups): flips to running, publishes the input, and drives the run with the per-turn thinking level (if any). Caller holds the session lock and has verified idle. */
3838
4809
  launchTask(entry, input, thinkingLevel) {
3839
4810
  const channel = this.deps.channels.get(entry.sessionId);
@@ -3912,15 +4883,16 @@ var SessionManager = class {
3912
4883
  return entry.approvals.decide(toolCallId, decision);
3913
4884
  }
3914
4885
  /**
3915
- * Mid-run steering: forward the text to the running Session (core delivers it between
3916
- * turns as a standalone `[user_steering]` user message — no SSE event of its own; the
3917
- * message arrives through the stream the drive loop already publishes). 409 when the
3918
- * Session isn't running a Task (idle / compacting / not loaded) or the run finished in the
3919
- * race window — the caller falls back to submitting a normal task.
4886
+ * Mid-run steering: forward the message to the running Session (core delivers it between
4887
+ * turns as a standalone `[user_steering]` user message followed by its images — no SSE
4888
+ * event of its own; the messages arrive through the stream the drive loop already
4889
+ * publishes). 409 when the Session isn't running a Task (idle / compacting / not loaded)
4890
+ * or the run finished in the race window — the caller falls back to submitting a normal
4891
+ * task, which carries the same text and images.
3920
4892
  */
3921
- steer(sessionId, text) {
4893
+ steer(sessionId, input) {
3922
4894
  const entry = this.entries.get(sessionId);
3923
- if (!entry || entry.status !== "running" || !entry.session.steer(text)) {
4895
+ if (!entry || entry.status !== "running" || !entry.session.steer(input)) {
3924
4896
  throw new HttpError(
3925
4897
  409,
3926
4898
  "not_running",
@@ -3929,6 +4901,21 @@ var SessionManager = class {
3929
4901
  }
3930
4902
  entry.lastActivityMs = Date.now();
3931
4903
  }
4904
+ /**
4905
+ * "Retry now" on the reconnect countdown: skip the in-progress backoff wait and fire
4906
+ * the next retry immediately (the attempt counter is unchanged — the skipped wait does
4907
+ * not consume an extra attempt). Returns false as a benign no-op when the session has
4908
+ * no active runtime or no reconnect wait is in progress — timing races (the wait
4909
+ * elapsed right before the click) must not surface as errors. Mirrors the steer seam:
4910
+ * manager → RuntimeSession → core Session → engine.
4911
+ */
4912
+ retryNow(sessionId) {
4913
+ const entry = this.entries.get(sessionId);
4914
+ if (!entry || entry.status === "idle") return false;
4915
+ const skipped = entry.session.skipReconnectWait();
4916
+ if (skipped) entry.lastActivityMs = Date.now();
4917
+ return skipped;
4918
+ }
3932
4919
  /**
3933
4920
  * Interrupt the current Task/compaction: pending approvals converge to deny first,
3934
4921
  * then the AbortSignal fires. Returns false if nothing is in progress (the route
@@ -4204,6 +5191,7 @@ var SessionManager = class {
4204
5191
  }
4205
5192
  }
4206
5193
  }
5194
+ this.liveTail.observe(entry.sessionId, msg);
4207
5195
  this.deps.channels.get(entry.sessionId).publish(msg);
4208
5196
  watcher?.observe(msg);
4209
5197
  try {
@@ -4220,6 +5208,7 @@ var SessionManager = class {
4220
5208
  this.deps.errors?.record({ source: "session", err, ctx, code: "session_run_failed" });
4221
5209
  } finally {
4222
5210
  watcher?.close();
5211
+ this.liveTail.clear(entry.sessionId);
4223
5212
  entry.approvals.denyAll();
4224
5213
  entry.status = "idle";
4225
5214
  entry.abort = null;
@@ -4267,7 +5256,7 @@ var SessionManager = class {
4267
5256
  const childSid = msg.origin[msg.origin.length - 1];
4268
5257
  if (children.has(childSid)) return null;
4269
5258
  const p = msg.payload;
4270
- const agentId = path8.basename(path8.dirname(p.agent_state));
5259
+ const agentId = path9.basename(path9.dirname(p.agent_state));
4271
5260
  if (!agentId || agentId === "." || agentId === "..") return null;
4272
5261
  const source = asSessionSource(p.source) ?? "subagent";
4273
5262
  this.deps.sources.set(childSid, source);
@@ -4746,7 +5735,7 @@ var UsageRecorder = class {
4746
5735
  };
4747
5736
 
4748
5737
  // src/services/ids.ts
4749
- import { randomBytes as randomBytes4 } from "crypto";
5738
+ import { randomBytes as randomBytes5 } from "crypto";
4750
5739
  var SEMANTIC_ID_PATTERN = /^[a-z][a-z0-9_]{1,63}$/;
4751
5740
  var USERNAME_PATTERN = /^[a-z][a-z0-9_]{1,31}$/;
4752
5741
  var PROJECT_SUFFIX_PATTERN = /^[a-z0-9_]+$/;
@@ -4822,7 +5811,7 @@ var AdminService = class {
4822
5811
  };
4823
5812
 
4824
5813
  // src/services/agent-config-service.ts
4825
- import fs10 from "fs/promises";
5814
+ import fs11 from "fs/promises";
4826
5815
  import { parseDocument, parse as parseYaml } from "yaml";
4827
5816
  import {
4828
5817
  agentsMdPath,
@@ -4837,8 +5826,8 @@ import {
4837
5826
  } from "@prismshadow/penguin-core";
4838
5827
 
4839
5828
  // src/services/project-config-service.ts
4840
- import fs9 from "fs/promises";
4841
- import path9 from "path";
5829
+ import fs10 from "fs/promises";
5830
+ import path10 from "path";
4842
5831
  import { parse as parseToml2 } from "smol-toml";
4843
5832
  import {
4844
5833
  GenerativeModel,
@@ -4890,11 +5879,24 @@ var ProjectConfigService = class {
4890
5879
  filePath(projectId) {
4891
5880
  return projectConfigPath(this.root, projectId);
4892
5881
  }
5882
+ /**
5883
+ * When the Project config (models/credentials) last changed: the config file's mtime as
5884
+ * ISO — an honest persistent source that survives server restarts (every models update
5885
+ * rewrites the file). undefined when the file doesn't exist yet.
5886
+ */
5887
+ async configUpdatedAt(projectId) {
5888
+ try {
5889
+ const st = await fs10.stat(this.filePath(projectId));
5890
+ return st.mtime.toISOString();
5891
+ } catch {
5892
+ return void 0;
5893
+ }
5894
+ }
4893
5895
  /** Reads the raw TOML object; returns an empty object if the file doesn't exist (does not write to disk). */
4894
5896
  async readRaw(projectId) {
4895
5897
  let raw;
4896
5898
  try {
4897
- raw = await fs9.readFile(this.filePath(projectId), "utf8");
5899
+ raw = await fs10.readFile(this.filePath(projectId), "utf8");
4898
5900
  } catch (err) {
4899
5901
  if (err.code === "ENOENT") return {};
4900
5902
  throw err;
@@ -4909,9 +5911,9 @@ var ProjectConfigService = class {
4909
5911
  */
4910
5912
  async writeRaw(projectId, data) {
4911
5913
  const file = this.filePath(projectId);
4912
- await fs9.mkdir(path9.dirname(file), { recursive: true });
4913
- await fs9.writeFile(file, renderProjectConfigToml(data), { encoding: "utf8", mode: 384 });
4914
- await fs9.chmod(file, 384);
5914
+ await fs10.mkdir(path10.dirname(file), { recursive: true });
5915
+ await fs10.writeFile(file, renderProjectConfigToml(data), { encoding: "utf8", mode: 384 });
5916
+ await fs10.chmod(file, 384);
4915
5917
  }
4916
5918
  /**
4917
5919
  * Initial config for a newly created Project: display name + preset built-in
@@ -4955,6 +5957,15 @@ var ProjectConfigService = class {
4955
5957
  const raw = await this.readRaw(projectId);
4956
5958
  return typeof raw.name === "string" ? raw.name : void 0;
4957
5959
  }
5960
+ /**
5961
+ * Rewrites the display name, preserving every other field (models, credentials, default
5962
+ * model): read-modify-write of the same toml, like ensurePresetModels. The id itself is
5963
+ * immutable — only this label changes.
5964
+ */
5965
+ async setName(projectId, name) {
5966
+ const raw = await this.readRaw(projectId);
5967
+ await this.writeRaw(projectId, { ...raw, name });
5968
+ }
4958
5969
  /** Paired reference of the default Model; returns undefined if unconfigured (or in the old string format). */
4959
5970
  async getDefaultModelRef(projectId) {
4960
5971
  const raw = await this.readRaw(projectId);
@@ -5107,9 +6118,11 @@ var ProjectConfigService = class {
5107
6118
  provider: ref.provider,
5108
6119
  modelId: ref.model_id
5109
6120
  });
6121
+ const updatedAt = await this.configUpdatedAt(projectId);
5110
6122
  return {
5111
6123
  ...defaultRef !== void 0 ? { defaultModel: toDto(defaultRef) } : {},
5112
6124
  ...visionRef !== void 0 ? { visionModel: toDto(visionRef) } : {},
6125
+ ...updatedAt !== void 0 ? { updatedAt } : {},
5113
6126
  models
5114
6127
  };
5115
6128
  }
@@ -5273,7 +6286,7 @@ var AgentConfigService = class {
5273
6286
  /** Whether the Agent exists (determined by the presence of system_config.yaml, matching the CLI's convention). */
5274
6287
  async exists(projectId, agentId) {
5275
6288
  try {
5276
- await fs10.access(systemConfigPath(this.root, projectId, agentId));
6289
+ await fs11.access(systemConfigPath(this.root, projectId, agentId));
5277
6290
  return true;
5278
6291
  } catch {
5279
6292
  return false;
@@ -5291,7 +6304,7 @@ var AgentConfigService = class {
5291
6304
  */
5292
6305
  async readCardMeta(projectId, agentId) {
5293
6306
  try {
5294
- const raw = await fs10.readFile(systemConfigPath(this.root, projectId, agentId), "utf8");
6307
+ const raw = await fs11.readFile(systemConfigPath(this.root, projectId, agentId), "utf8");
5295
6308
  const parsed = asRecord(parseYaml(raw));
5296
6309
  const tools = asRecord(parsed.tools);
5297
6310
  const countOf = (v) => Array.isArray(v) ? v.length : 0;
@@ -5309,14 +6322,14 @@ var AgentConfigService = class {
5309
6322
  async getConfig(projectId, agentId) {
5310
6323
  await this.requireExists(projectId, agentId);
5311
6324
  const yamlPath = systemConfigPath(this.root, projectId, agentId);
5312
- const systemConfigYaml = await fs10.readFile(yamlPath, "utf8");
6325
+ const systemConfigYaml = await fs11.readFile(yamlPath, "utf8");
5313
6326
  const parsed = asRecord(parseYaml(systemConfigYaml));
5314
6327
  const model = asRecord(parsed.model);
5315
6328
  const compaction = asRecord(parsed.compaction);
5316
6329
  const tools = asRecord(parsed.tools);
5317
6330
  let agentsMd = "";
5318
6331
  try {
5319
- agentsMd = await fs10.readFile(agentsMdPath(this.root, projectId, agentId), "utf8");
6332
+ agentsMd = await fs11.readFile(agentsMdPath(this.root, projectId, agentId), "utf8");
5320
6333
  } catch {
5321
6334
  }
5322
6335
  const modelDto = {
@@ -5359,7 +6372,7 @@ var AgentConfigService = class {
5359
6372
  await this.applyConfigUpdate(projectId, agentId, req.config);
5360
6373
  }
5361
6374
  if (req.agentsMd !== void 0) {
5362
- await fs10.writeFile(agentsMdPath(this.root, projectId, agentId), req.agentsMd, "utf8");
6375
+ await fs11.writeFile(agentsMdPath(this.root, projectId, agentId), req.agentsMd, "utf8");
5363
6376
  }
5364
6377
  }
5365
6378
  /**
@@ -5377,9 +6390,9 @@ var AgentConfigService = class {
5377
6390
  async applyConfigUpdate(projectId, agentId, config2) {
5378
6391
  const cfg = config2;
5379
6392
  const yamlPath = systemConfigPath(this.root, projectId, agentId);
5380
- const doc = parseDocument(await fs10.readFile(yamlPath, "utf8"));
5381
- const setIfProvided = (path17, value) => {
5382
- if (value !== void 0) doc.setIn(path17, value);
6393
+ const doc = parseDocument(await fs11.readFile(yamlPath, "utf8"));
6394
+ const setIfProvided = (path18, value) => {
6395
+ if (value !== void 0) doc.setIn(path18, value);
5383
6396
  };
5384
6397
  setIfProvided(["name"], optionalString(cfg, "name", { maxLen: 100, label: "name" }));
5385
6398
  setIfProvided(
@@ -5428,7 +6441,7 @@ var AgentConfigService = class {
5428
6441
  if (cfg.mcpServers !== void 0) {
5429
6442
  doc.setIn(["tools", "mcpServers"], validateMcpServers(cfg.mcpServers));
5430
6443
  }
5431
- await fs10.writeFile(yamlPath, doc.toString(), "utf8");
6444
+ await fs11.writeFile(yamlPath, doc.toString(), "utf8");
5432
6445
  }
5433
6446
  /** Read the Agent vault (agent_state/.vault.toml): values are always masked, plaintext is never sent to the client. */
5434
6447
  async getVault(projectId, agentId) {
@@ -5525,8 +6538,8 @@ function validateMcpServers(value) {
5525
6538
  }
5526
6539
 
5527
6540
  // src/services/agent-service.ts
5528
- import fs11 from "fs/promises";
5529
- import path10 from "path";
6541
+ import fs12 from "fs/promises";
6542
+ import path11 from "path";
5530
6543
  import {
5531
6544
  agentDir,
5532
6545
  agentsDir as agentsDir2,
@@ -5553,7 +6566,7 @@ var AgentService = class {
5553
6566
  const known = new Map(this.agents.list(projectId).map((r) => [r.agentId, r]));
5554
6567
  let entries = [];
5555
6568
  try {
5556
- const dirents = await fs11.readdir(agentsDir2(this.root, projectId), { withFileTypes: true });
6569
+ const dirents = await fs12.readdir(agentsDir2(this.root, projectId), { withFileTypes: true });
5557
6570
  entries = dirents.filter((d) => d.isDirectory()).map((d) => d.name);
5558
6571
  } catch {
5559
6572
  }
@@ -5562,7 +6575,7 @@ var AgentService = class {
5562
6575
  const configPath = systemConfigPath2(this.root, projectId, agentId);
5563
6576
  let createdAt;
5564
6577
  try {
5565
- const stat = await fs11.stat(configPath);
6578
+ const stat = await fs12.stat(configPath);
5566
6579
  createdAt = (stat.birthtime.getTime() > 0 ? stat.birthtime : stat.mtime).toISOString();
5567
6580
  } catch {
5568
6581
  continue;
@@ -5606,7 +6619,7 @@ var AgentService = class {
5606
6619
  /** Number of scheduled tasks: count of .toml files under schedule/ (0 if the directory doesn't exist). */
5607
6620
  async scheduleCount(projectId, agentId) {
5608
6621
  try {
5609
- const names = await fs11.readdir(scheduleDir2(this.root, projectId, agentId));
6622
+ const names = await fs12.readdir(scheduleDir2(this.root, projectId, agentId));
5610
6623
  return names.filter((n) => n.endsWith(".toml")).length;
5611
6624
  } catch {
5612
6625
  return 0;
@@ -5617,14 +6630,14 @@ var AgentService = class {
5617
6630
  const base = skillsDir2(this.root, projectId, agentId);
5618
6631
  let dirents;
5619
6632
  try {
5620
- dirents = await fs11.readdir(base, { withFileTypes: true });
6633
+ dirents = await fs12.readdir(base, { withFileTypes: true });
5621
6634
  } catch {
5622
6635
  return 0;
5623
6636
  }
5624
6637
  const present = await Promise.all(
5625
6638
  dirents.filter((d) => d.isDirectory()).map(async (d) => {
5626
6639
  try {
5627
- await fs11.access(path10.join(base, d.name, "SKILL.md"));
6640
+ await fs12.access(path11.join(base, d.name, "SKILL.md"));
5628
6641
  return true;
5629
6642
  } catch {
5630
6643
  return false;
@@ -5642,7 +6655,7 @@ var AgentService = class {
5642
6655
  const times = await Promise.all(
5643
6656
  paths.map(async (p) => {
5644
6657
  try {
5645
- return (await fs11.stat(p)).mtime.getTime();
6658
+ return (await fs12.stat(p)).mtime.getTime();
5646
6659
  } catch {
5647
6660
  return 0;
5648
6661
  }
@@ -5667,7 +6680,7 @@ var AgentService = class {
5667
6680
  "Built-in Agents (default_agent) are provisioned with the Project and cannot be deleted from the web."
5668
6681
  );
5669
6682
  }
5670
- await fs11.rm(agentDir(this.root, projectId, agentId), { recursive: true, force: true });
6683
+ await fs12.rm(agentDir(this.root, projectId, agentId), { recursive: true, force: true });
5671
6684
  this.agents.delete(projectId, agentId);
5672
6685
  }
5673
6686
  /**
@@ -5684,7 +6697,7 @@ var AgentService = class {
5684
6697
  `Agent id must be 2\u201364 characters: ${SEMANTIC_ID_RULE}.`
5685
6698
  );
5686
6699
  }
5687
- const taken = this.agents.exists(projectId, agentId) || await fs11.stat(agentDir(this.root, projectId, agentId)).then(
6700
+ const taken = this.agents.exists(projectId, agentId) || await fs12.stat(agentDir(this.root, projectId, agentId)).then(
5688
6701
  () => true,
5689
6702
  () => false
5690
6703
  );
@@ -5698,7 +6711,7 @@ var AgentService = class {
5698
6711
  config: { name: displayName, ...description !== void 0 ? { description } : {} }
5699
6712
  });
5700
6713
  } catch (err) {
5701
- await fs11.rm(agentDir(this.root, projectId, agentId), { recursive: true, force: true }).catch(() => {
6714
+ await fs12.rm(agentDir(this.root, projectId, agentId), { recursive: true, force: true }).catch(() => {
5702
6715
  });
5703
6716
  throw err;
5704
6717
  }
@@ -5722,120 +6735,205 @@ var AgentService = class {
5722
6735
  };
5723
6736
 
5724
6737
  // src/services/benchmark-service.ts
5725
- import fs12 from "fs/promises";
5726
- import path11 from "path";
6738
+ import fs13 from "fs/promises";
6739
+ import path12 from "path";
5727
6740
  import { parse as parseToml3 } from "smol-toml";
5728
6741
  import { parse as parseYaml2 } from "yaml";
5729
6742
  import { benchmarksDir } from "@prismshadow/penguin-core";
6743
+ var STATEMENT_TITLE_READ_BYTES = 64 * 1024;
5730
6744
  function asRecord2(v) {
5731
6745
  return v !== null && typeof v === "object" && !Array.isArray(v) ? v : {};
5732
6746
  }
5733
6747
  function numberOr(v) {
5734
6748
  return typeof v === "number" && Number.isFinite(v) ? v : void 0;
5735
6749
  }
6750
+ function scoreOr(v) {
6751
+ const value = numberOr(v);
6752
+ return value !== void 0 && value >= 0 && value <= 100 ? value : void 0;
6753
+ }
6754
+ function nonNegativeOr(v) {
6755
+ const value = numberOr(v);
6756
+ return value !== void 0 && value >= 0 ? value : void 0;
6757
+ }
6758
+ function nonNegativeIntegerOr(v) {
6759
+ const value = nonNegativeOr(v);
6760
+ return value !== void 0 && Number.isInteger(value) ? value : void 0;
6761
+ }
6762
+ function nullableCostOr(v) {
6763
+ if (v === null) return null;
6764
+ return nonNegativeOr(v);
6765
+ }
5736
6766
  function stringOr(v) {
5737
6767
  return typeof v === "string" && v !== "" ? v : void 0;
5738
6768
  }
6769
+ function isWithin(parent, child) {
6770
+ const relative = path12.relative(parent, child);
6771
+ return relative !== "" && !relative.startsWith("..") && !path12.isAbsolute(relative);
6772
+ }
6773
+ function statementTitle(statement, fallback) {
6774
+ const heading = /^#\s+(.+)$/m.exec(statement)?.[1]?.trim();
6775
+ return heading?.replace(/^Case\s+\d+\s*:\s*/i, "") || fallback;
6776
+ }
6777
+ async function readStatementTitle(readme, fallback) {
6778
+ const handle = await fs13.open(readme, "r");
6779
+ try {
6780
+ const buffer = Buffer.alloc(STATEMENT_TITLE_READ_BYTES);
6781
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
6782
+ return statementTitle(buffer.subarray(0, bytesRead).toString("utf8"), fallback);
6783
+ } finally {
6784
+ await handle.close();
6785
+ }
6786
+ }
5739
6787
  function toRun(v) {
5740
6788
  const r = asRecord2(v);
5741
- const score = numberOr(r.score);
5742
- if (score === void 0) return null;
5743
- const cost = numberOr(r.cost);
5744
- const durationMs = numberOr(r.duration_ms);
6789
+ const score = scoreOr(r.score);
6790
+ const cost = nullableCostOr(r.cost);
6791
+ const durationMs = nonNegativeIntegerOr(r.duration_ms);
5745
6792
  const sessionId = stringOr(r.session_id);
5746
- return {
5747
- score,
5748
- ...cost !== void 0 ? { cost } : {},
5749
- ...durationMs !== void 0 ? { durationMs } : {},
5750
- ...sessionId !== void 0 ? { sessionId } : {}
5751
- };
5752
- }
5753
- function averageOf(runs, pick) {
5754
- const values = runs.map(pick).filter((v) => v !== void 0);
5755
- if (values.length === 0) return void 0;
5756
- return values.reduce((a, b) => a + b, 0) / values.length;
6793
+ if (score === void 0 || cost === void 0 || durationMs === void 0 || !sessionId)
6794
+ return null;
6795
+ return { score, cost, durationMs, sessionId };
5757
6796
  }
5758
6797
  function toCase(v) {
5759
6798
  const cr = asRecord2(v);
5760
6799
  const caseId = stringOr(cr.case);
5761
- if (caseId === void 0) return null;
5762
- const parsedRuns = Array.isArray(cr.runs) ? cr.runs.map(toRun).filter((r) => r !== null) : [];
5763
- const score = numberOr(cr.score) ?? averageOf(parsedRuns, (r) => r.score);
5764
- if (score === void 0) return null;
5765
- const cost = numberOr(cr.cost) ?? averageOf(parsedRuns, (r) => r.cost);
5766
- const durationMs = numberOr(cr.duration_ms) ?? averageOf(parsedRuns, (r) => r.durationMs);
5767
- const sessionId = stringOr(cr.session_id);
5768
- const runs = parsedRuns.length > 0 ? parsedRuns : [
5769
- // The old format is parsed as a single run: the case-level values are that run's raw result.
5770
- {
5771
- score,
5772
- ...cost !== void 0 ? { cost } : {},
5773
- ...durationMs !== void 0 ? { durationMs } : {},
5774
- ...sessionId !== void 0 ? { sessionId } : {}
5775
- }
5776
- ];
6800
+ const score = scoreOr(cr.score);
6801
+ const cost = nullableCostOr(cr.cost);
6802
+ const durationMs = nonNegativeIntegerOr(cr.duration_ms);
6803
+ if (!caseId || score === void 0 || cost === void 0 || durationMs === void 0 || "max_score" in cr || !Array.isArray(cr.runs) || cr.runs.length === 0) {
6804
+ return null;
6805
+ }
6806
+ const parsedRuns = cr.runs.map(toRun);
6807
+ if (parsedRuns.some((run) => run === null)) return null;
6808
+ const runs = parsedRuns;
5777
6809
  return {
5778
6810
  case: caseId,
5779
6811
  score,
5780
- ...cost !== void 0 ? { cost } : {},
5781
- ...durationMs !== void 0 ? { durationMs } : {},
5782
- ...sessionId !== void 0 ? { sessionId } : {},
6812
+ cost,
6813
+ durationMs,
5783
6814
  runs
5784
6815
  };
5785
6816
  }
5786
6817
  function toEvaluation(v) {
5787
6818
  const r = asRecord2(v);
5788
6819
  const time = r.time instanceof Date ? r.time.toISOString() : r.time;
5789
- const score = numberOr(r.score);
5790
- if (typeof time !== "string" || time === "" || score === void 0) return null;
5791
- const cases = Array.isArray(r.cases) ? r.cases.map(toCase).filter((c) => c !== null) : [];
6820
+ const score = scoreOr(r.score);
6821
+ const cost = nullableCostOr(r.cost);
6822
+ const durationMs = nonNegativeIntegerOr(r.duration_ms);
5792
6823
  const summary = stringOr(r.summary);
5793
6824
  const summaryTitle = stringOr(r.summary_title);
5794
6825
  const modelId = stringOr(r.model_id);
5795
6826
  const provider = stringOr(r.provider);
5796
- const version = numberOr(r.version);
5797
- const cost = numberOr(r.cost);
5798
- const durationMs = numberOr(r.duration_ms);
6827
+ const thinkingLevel = stringOr(r.thinking_level);
6828
+ const version = nonNegativeIntegerOr(r.version);
6829
+ if (typeof time !== "string" || time === "" || score === void 0 || cost === void 0 || durationMs === void 0 || !modelId || !provider || !thinkingLevel || version === void 0 || version < 1 || !Array.isArray(r.cases) || r.cases.length === 0) {
6830
+ return null;
6831
+ }
6832
+ const parsedCases = r.cases.map(toCase);
6833
+ if (parsedCases.some((item) => item === null)) return null;
6834
+ const cases = parsedCases;
5799
6835
  return {
5800
6836
  time,
5801
6837
  ...summaryTitle !== void 0 ? { summaryTitle } : {},
5802
6838
  ...summary !== void 0 ? { summary } : {},
5803
- ...modelId !== void 0 ? { modelId } : {},
5804
- ...provider !== void 0 ? { provider } : {},
6839
+ modelId,
6840
+ provider,
6841
+ thinkingLevel,
5805
6842
  score,
5806
- ...version !== void 0 ? { version } : {},
5807
- ...cost !== void 0 ? { cost } : {},
5808
- ...durationMs !== void 0 ? { durationMs } : {},
6843
+ version,
6844
+ cost,
6845
+ durationMs,
5809
6846
  cases
5810
6847
  };
5811
6848
  }
5812
6849
  var BenchmarkService = class {
5813
- constructor(root) {
6850
+ constructor(root, workspaceFiles) {
5814
6851
  this.root = root;
6852
+ this.workspaceFiles = workspaceFiles;
5815
6853
  }
5816
6854
  root;
6855
+ workspaceFiles;
5817
6856
  async list(projectId, agentId) {
5818
6857
  const dir = benchmarksDir(this.root, projectId, agentId);
5819
6858
  let items;
5820
6859
  try {
5821
- const entries = await fs12.readdir(dir, { withFileTypes: true });
6860
+ const entries = await fs13.readdir(dir, { withFileTypes: true });
5822
6861
  items = entries.map((e) => ({ name: e.name, isDir: e.isDirectory() }));
5823
6862
  } catch {
5824
6863
  return { benchmarks: [] };
5825
6864
  }
5826
6865
  const benchmarks = [];
5827
6866
  for (const item of items.filter((i) => i.isDir).sort((a, b) => a.name.localeCompare(b.name))) {
5828
- benchmarks.push(await this.readBenchmark(path11.join(dir, item.name), item.name));
6867
+ benchmarks.push(await this.readBenchmark(path12.join(dir, item.name), item.name));
5829
6868
  }
5830
6869
  return { benchmarks };
5831
6870
  }
6871
+ async listCases(projectId, agentId, benchmarkId) {
6872
+ const baseDir = benchmarksDir(this.root, projectId, agentId);
6873
+ const benchDir = path12.join(baseDir, benchmarkId);
6874
+ let entries;
6875
+ let realBaseDir;
6876
+ let realBenchDir;
6877
+ try {
6878
+ [entries, realBaseDir, realBenchDir] = await Promise.all([
6879
+ fs13.readdir(benchDir, { withFileTypes: true }),
6880
+ fs13.realpath(baseDir),
6881
+ fs13.realpath(benchDir)
6882
+ ]);
6883
+ } catch {
6884
+ return { cases: [] };
6885
+ }
6886
+ if (!isWithin(realBaseDir, realBenchDir)) return { cases: [] };
6887
+ const cases = [];
6888
+ for (const entry of entries.filter((item) => item.isDirectory() && item.name.startsWith("CASE-")).sort((a, b) => a.name.localeCompare(b.name))) {
6889
+ const fallback = { id: entry.name, title: entry.name };
6890
+ try {
6891
+ const statementDir = await this.statementRoot(projectId, agentId, benchmarkId, entry.name);
6892
+ const realReadme = await fs13.realpath(path12.join(statementDir, "README.md"));
6893
+ if (!isWithin(statementDir, realReadme)) throw new Error("README escapes Statement");
6894
+ cases.push({
6895
+ id: entry.name,
6896
+ title: await readStatementTitle(realReadme, entry.name)
6897
+ });
6898
+ } catch {
6899
+ cases.push(fallback);
6900
+ }
6901
+ }
6902
+ return { cases };
6903
+ }
6904
+ async listCaseFiles(projectId, agentId, benchmarkId, caseId, rel) {
6905
+ const statementDir = await this.statementRoot(projectId, agentId, benchmarkId, caseId);
6906
+ return this.workspaceFiles.list(statementDir, rel);
6907
+ }
6908
+ async readCaseFile(projectId, agentId, benchmarkId, caseId, rel, options) {
6909
+ const statementDir = await this.statementRoot(projectId, agentId, benchmarkId, caseId);
6910
+ return this.workspaceFiles.read(statementDir, rel, options);
6911
+ }
6912
+ async statementRoot(projectId, agentId, benchmarkId, caseId) {
6913
+ const benchDir = path12.join(benchmarksDir(this.root, projectId, agentId), benchmarkId);
6914
+ const caseDir = path12.join(benchDir, caseId);
6915
+ const statementDir = path12.join(caseDir, "statement");
6916
+ try {
6917
+ const [realBenchDir, realCaseDir, realStatementDir] = await Promise.all([
6918
+ fs13.realpath(benchDir),
6919
+ fs13.realpath(caseDir),
6920
+ fs13.realpath(statementDir)
6921
+ ]);
6922
+ if (!isWithin(realBenchDir, realCaseDir) || path12.dirname(realStatementDir) !== realCaseDir || path12.basename(realStatementDir) !== "statement") {
6923
+ throw new Error("Statement path is not canonical");
6924
+ }
6925
+ return realStatementDir;
6926
+ } catch {
6927
+ throw new HttpError(404, "not_found", "Public Case Statement does not exist.");
6928
+ }
6929
+ }
5832
6930
  async readBenchmark(benchDir, id) {
5833
6931
  let title = id;
5834
6932
  let description;
5835
6933
  let runs;
5836
6934
  try {
5837
6935
  const config2 = asRecord2(
5838
- parseToml3(await fs12.readFile(path11.join(benchDir, "benchmark_config.toml"), "utf8"))
6936
+ parseToml3(await fs13.readFile(path12.join(benchDir, "benchmark_config.toml"), "utf8"))
5839
6937
  );
5840
6938
  if (typeof config2.title === "string" && config2.title !== "") title = config2.title;
5841
6939
  if (typeof config2.description === "string" && config2.description !== "") {
@@ -5850,7 +6948,7 @@ var BenchmarkService = class {
5850
6948
  let evaluations = [];
5851
6949
  try {
5852
6950
  const scoreboard = asRecord2(
5853
- parseYaml2(await fs12.readFile(path11.join(benchDir, "scoreboard.yaml"), "utf8"))
6951
+ parseYaml2(await fs13.readFile(path12.join(benchDir, "scoreboard.yaml"), "utf8"))
5854
6952
  );
5855
6953
  if (Array.isArray(scoreboard.evaluations)) {
5856
6954
  evaluations = scoreboard.evaluations.map(toEvaluation).filter((e) => e !== null);
@@ -5859,8 +6957,8 @@ var BenchmarkService = class {
5859
6957
  }
5860
6958
  let caseCount = 0;
5861
6959
  try {
5862
- const entries = await fs12.readdir(benchDir, { withFileTypes: true });
5863
- caseCount = entries.filter((e) => e.isDirectory()).length;
6960
+ const entries = await fs13.readdir(benchDir, { withFileTypes: true });
6961
+ caseCount = entries.filter((e) => e.isDirectory() && e.name.startsWith("CASE-")).length;
5864
6962
  } catch {
5865
6963
  }
5866
6964
  return {
@@ -5875,9 +6973,9 @@ var BenchmarkService = class {
5875
6973
  };
5876
6974
 
5877
6975
  // src/services/snapshot-service.ts
5878
- import { randomBytes as randomBytes5 } from "crypto";
5879
- import fs13 from "fs/promises";
5880
- import path12 from "path";
6976
+ import { randomBytes as randomBytes6 } from "crypto";
6977
+ import fs14 from "fs/promises";
6978
+ import path13 from "path";
5881
6979
  import * as tar from "tar";
5882
6980
  import { parse as parseYaml3 } from "yaml";
5883
6981
  import {
@@ -5890,7 +6988,7 @@ import {
5890
6988
  } from "@prismshadow/penguin-core";
5891
6989
  var VAULT_BASENAME = ".vault.toml";
5892
6990
  function isVaultEntry(entryPath) {
5893
- return path12.posix.basename(entryPath.replaceAll("\\", "/")) === VAULT_BASENAME;
6991
+ return path13.posix.basename(entryPath.replaceAll("\\", "/")) === VAULT_BASENAME;
5894
6992
  }
5895
6993
  var SnapshotService = class {
5896
6994
  constructor(root) {
@@ -5901,7 +6999,7 @@ var SnapshotService = class {
5901
6999
  async currentVersion(projectId, agentId) {
5902
7000
  let raw;
5903
7001
  try {
5904
- raw = await fs13.readFile(systemConfigPath3(this.root, projectId, agentId), "utf8");
7002
+ raw = await fs14.readFile(systemConfigPath3(this.root, projectId, agentId), "utf8");
5905
7003
  } catch {
5906
7004
  throw new HttpError(404, "agent_not_found", "Agent does not exist.");
5907
7005
  }
@@ -5914,14 +7012,14 @@ var SnapshotService = class {
5914
7012
  async ensureSnapshot(projectId, agentId) {
5915
7013
  const version = await this.currentVersion(projectId, agentId);
5916
7014
  const dir = snapshotsDir(this.root, projectId, agentId);
5917
- const file = path12.join(dir, `v${version}.tar.gz`);
7015
+ const file = path13.join(dir, `v${version}.tar.gz`);
5918
7016
  try {
5919
- await fs13.access(file);
7017
+ await fs14.access(file);
5920
7018
  return { version, file };
5921
7019
  } catch {
5922
7020
  }
5923
- await fs13.mkdir(dir, { recursive: true });
5924
- const tmp = `${file}.tmp-${randomBytes5(4).toString("hex")}`;
7021
+ await fs14.mkdir(dir, { recursive: true });
7022
+ const tmp = `${file}.tmp-${randomBytes6(4).toString("hex")}`;
5925
7023
  await tar.create(
5926
7024
  {
5927
7025
  gzip: true,
@@ -5932,7 +7030,7 @@ var SnapshotService = class {
5932
7030
  },
5933
7031
  ["agent_state"]
5934
7032
  );
5935
- await fs13.rename(tmp, file);
7033
+ await fs14.rename(tmp, file);
5936
7034
  return { version, file };
5937
7035
  }
5938
7036
  /** Export: automatically packs a snapshot first if none exists, returns the info needed for download. */
@@ -5949,13 +7047,13 @@ var SnapshotService = class {
5949
7047
  async importArchive(projectId, agentId, archive, confirm) {
5950
7048
  const current = await this.currentVersion(projectId, agentId);
5951
7049
  const base = agentDir2(this.root, projectId, agentId);
5952
- const staging = path12.join(base, `.import-${randomBytes5(6).toString("hex")}`);
5953
- await fs13.mkdir(staging, { recursive: true });
7050
+ const staging = path13.join(base, `.import-${randomBytes6(6).toString("hex")}`);
7051
+ await fs14.mkdir(staging, { recursive: true });
5954
7052
  try {
5955
- const archiveFile = path12.join(staging, "archive.tar.gz");
5956
- await fs13.writeFile(archiveFile, archive);
5957
- const extractDir = path12.join(staging, "extracted");
5958
- await fs13.mkdir(extractDir, { recursive: true });
7053
+ const archiveFile = path13.join(staging, "archive.tar.gz");
7054
+ await fs14.writeFile(archiveFile, archive);
7055
+ const extractDir = path13.join(staging, "extracted");
7056
+ await fs14.mkdir(extractDir, { recursive: true });
5959
7057
  try {
5960
7058
  await tar.extract({
5961
7059
  file: archiveFile,
@@ -5965,10 +7063,10 @@ var SnapshotService = class {
5965
7063
  } catch {
5966
7064
  throw badRequest("Import failed: not a valid tar.gz snapshot package.");
5967
7065
  }
5968
- const configPath = path12.join(extractDir, "agent_state", "system_config.yaml");
7066
+ const configPath = path13.join(extractDir, "agent_state", "system_config.yaml");
5969
7067
  let parsed;
5970
7068
  try {
5971
- parsed = parseYaml3(await fs13.readFile(configPath, "utf8"));
7069
+ parsed = parseYaml3(await fs14.readFile(configPath, "utf8"));
5972
7070
  } catch {
5973
7071
  throw badRequest("Import failed: the package is missing agent_state/system_config.yaml.");
5974
7072
  }
@@ -5991,37 +7089,37 @@ var SnapshotService = class {
5991
7089
  }
5992
7090
  await this.ensureSnapshot(projectId, agentId);
5993
7091
  const stateDir = agentStateDir2(this.root, projectId, agentId);
5994
- const incomingState = path12.join(extractDir, "agent_state");
7092
+ const incomingState = path13.join(extractDir, "agent_state");
5995
7093
  let vault = null;
5996
7094
  try {
5997
- vault = await fs13.readFile(agentVaultPath(this.root, projectId, agentId));
7095
+ vault = await fs14.readFile(agentVaultPath(this.root, projectId, agentId));
5998
7096
  } catch {
5999
7097
  }
6000
7098
  if (vault !== null) {
6001
- await fs13.writeFile(path12.join(incomingState, VAULT_BASENAME), vault, { mode: 384 });
7099
+ await fs14.writeFile(path13.join(incomingState, VAULT_BASENAME), vault, { mode: 384 });
6002
7100
  }
6003
- const trash = path12.join(staging, "replaced-agent_state");
6004
- await fs13.rename(stateDir, trash);
7101
+ const trash = path13.join(staging, "replaced-agent_state");
7102
+ await fs14.rename(stateDir, trash);
6005
7103
  try {
6006
- await fs13.rename(incomingState, stateDir);
7104
+ await fs14.rename(incomingState, stateDir);
6007
7105
  } catch (err) {
6008
- await fs13.rename(trash, stateDir);
7106
+ await fs14.rename(trash, stateDir);
6009
7107
  throw err;
6010
7108
  }
6011
7109
  return { version: incoming };
6012
7110
  } finally {
6013
- await fs13.rm(staging, { recursive: true, force: true });
7111
+ await fs14.rm(staging, { recursive: true, force: true });
6014
7112
  }
6015
7113
  }
6016
7114
  };
6017
7115
 
6018
7116
  // src/services/project-service.ts
6019
- import fs14 from "fs/promises";
7117
+ import fs15 from "fs/promises";
6020
7118
  import { DEFAULT_PROJECT_ID, projectDir, provisionProjectAgents } from "@prismshadow/penguin-core";
6021
7119
  var ABORT_SETTLE_TIMEOUT_MS = 5e3;
6022
- async function dirExists(path17) {
7120
+ async function dirExists(path18) {
6023
7121
  try {
6024
- const stat = await fs14.stat(path17);
7122
+ const stat = await fs15.stat(path18);
6025
7123
  return stat.isDirectory();
6026
7124
  } catch {
6027
7125
  return false;
@@ -6146,12 +7244,12 @@ var ProjectService = class {
6146
7244
  throw err;
6147
7245
  }
6148
7246
  try {
6149
- await fs14.mkdir(projectDir(this.deps.root, projectId), { recursive: true });
7247
+ await fs15.mkdir(projectDir(this.deps.root, projectId), { recursive: true });
6150
7248
  await this.deps.projectConfig.writeInitialConfig(projectId, displayName);
6151
7249
  await this.provisionBuiltinAgents(projectId);
6152
7250
  } catch (err) {
6153
7251
  this.deps.projects.delete(projectId);
6154
- await fs14.rm(projectDir(this.deps.root, projectId), { recursive: true, force: true }).catch(() => {
7252
+ await fs15.rm(projectDir(this.deps.root, projectId), { recursive: true, force: true }).catch(() => {
6155
7253
  });
6156
7254
  throw err;
6157
7255
  }
@@ -6184,6 +7282,23 @@ var ProjectService = class {
6184
7282
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
6185
7283
  });
6186
7284
  }
7285
+ /**
7286
+ * Rename a Project's display name (owner): the id stays immutable — it names the directory,
7287
+ * the Workspace paths and every stored reference — so only the label in project_config.toml
7288
+ * changes. Returns the refreshed summary so the caller can swap it into its list without a
7289
+ * reload.
7290
+ */
7291
+ async renameProject(userId, projectId, name) {
7292
+ const row = this.requireProjectOwner(userId, projectId);
7293
+ await this.deps.projectConfig.setName(projectId, name);
7294
+ return {
7295
+ projectId,
7296
+ name,
7297
+ role: "owner",
7298
+ ownerUserId: row.ownerUserId,
7299
+ createdAt: row.createdAt
7300
+ };
7301
+ }
6187
7302
  /**
6188
7303
  * Delete a Project (owner): default_project is refused; deleting the user's
6189
7304
  * **last accessible Project** is refused too (deleting it would leave the list
@@ -6231,7 +7346,8 @@ var ProjectService = class {
6231
7346
  this.deps.usage.deleteByProject(projectId);
6232
7347
  this.deps.errors.deleteByProject(projectId);
6233
7348
  this.deps.schedules.deleteByProject(projectId);
6234
- await fs14.rm(projectDir(this.deps.root, projectId), { recursive: true, force: true });
7349
+ this.deps.goals.deleteByProject(projectId);
7350
+ await fs15.rm(projectDir(this.deps.root, projectId), { recursive: true, force: true });
6235
7351
  }
6236
7352
  // —— Member authorization ——
6237
7353
  /** Member list: owner (role=owner) + members. */
@@ -6302,7 +7418,7 @@ var ProjectService = class {
6302
7418
  };
6303
7419
 
6304
7420
  // src/services/session-service.ts
6305
- import path13 from "path";
7421
+ import path14 from "path";
6306
7422
  import { open, readdir as readdir2 } from "fs/promises";
6307
7423
  import {
6308
7424
  createAgent as createAgent2,
@@ -6484,7 +7600,7 @@ var SessionService = class {
6484
7600
  };
6485
7601
  const dir = tracesDir2(this.deps.root, projectId, agentId);
6486
7602
  for (const dateDir of await listDirsSafe(dir)) {
6487
- for (const file of await listFilesSafe(path13.join(dir, dateDir))) {
7603
+ for (const file of await listFilesSafe(path14.join(dir, dateDir))) {
6488
7604
  const match = TRACE_FILE_RE.exec(file);
6489
7605
  if (match) mark(dateDir, match[1]);
6490
7606
  }
@@ -6600,14 +7716,14 @@ var SessionService = class {
6600
7716
  const dir = tracesDir2(this.deps.root, projectId, agentId);
6601
7717
  const out = /* @__PURE__ */ new Map();
6602
7718
  for (const dateDir of await listDirsSafe(dir)) {
6603
- for (const file of await listFilesSafe(path13.join(dir, dateDir))) {
7719
+ for (const file of await listFilesSafe(path14.join(dir, dateDir))) {
6604
7720
  const match = TRACE_FILE_RE.exec(file);
6605
7721
  if (!match) continue;
6606
7722
  const sessionId = match[1];
6607
7723
  const index = Number(match[2]);
6608
7724
  const cur = out.get(sessionId);
6609
7725
  if (!cur || index < cur.index) {
6610
- out.set(sessionId, { path: path13.join(dir, dateDir, file), index });
7726
+ out.set(sessionId, { path: path14.join(dir, dateDir, file), index });
6611
7727
  }
6612
7728
  }
6613
7729
  }
@@ -6670,15 +7786,18 @@ async function listFilesSafe(dir) {
6670
7786
  }
6671
7787
 
6672
7788
  // src/services/trace-service.ts
6673
- import fs15 from "fs/promises";
6674
- import path14 from "path";
7789
+ import fs16 from "fs/promises";
7790
+ import path15 from "path";
6675
7791
  import {
6676
7792
  agentsDir as agentsDir3,
7793
+ isSessionMeta as isSessionMeta5,
7794
+ parseTraceLines as parseTraceLines2,
6677
7795
  parseUserSteeringText,
6678
7796
  readTraceTolerant as readTraceTolerant2,
6679
7797
  tracesDir as tracesDir3
6680
7798
  } from "@prismshadow/penguin-core";
6681
7799
  var TRACE_FILE_RE2 = /^(.+)_(\d{3})\.jsonl$/;
7800
+ var IMPORT_SESSION_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
6682
7801
  var MAX_SUBAGENT_DEPTH = 4;
6683
7802
  function subagentPointer(msg) {
6684
7803
  if (msg.type !== "event_msg") return null;
@@ -6690,7 +7809,7 @@ function subagentPointer(msg) {
6690
7809
  }
6691
7810
  async function listDirs(dir) {
6692
7811
  try {
6693
- const entries = await fs15.readdir(dir, { withFileTypes: true });
7812
+ const entries = await fs16.readdir(dir, { withFileTypes: true });
6694
7813
  return entries.filter((e) => e.isDirectory()).map((e) => e.name);
6695
7814
  } catch {
6696
7815
  return [];
@@ -6698,7 +7817,7 @@ async function listDirs(dir) {
6698
7817
  }
6699
7818
  async function listFiles(dir) {
6700
7819
  try {
6701
- const entries = await fs15.readdir(dir, { withFileTypes: true });
7820
+ const entries = await fs16.readdir(dir, { withFileTypes: true });
6702
7821
  return entries.filter((e) => e.isFile()).map((e) => e.name);
6703
7822
  } catch {
6704
7823
  return [];
@@ -6714,10 +7833,10 @@ var TraceService = class {
6714
7833
  const dir = tracesDir3(this.root, projectId, agentId);
6715
7834
  const out = [];
6716
7835
  for (const dateDir of await listDirs(dir)) {
6717
- for (const file of await listFiles(path14.join(dir, dateDir))) {
7836
+ for (const file of await listFiles(path15.join(dir, dateDir))) {
6718
7837
  const match = TRACE_FILE_RE2.exec(file);
6719
7838
  if (!match || match[1] !== sessionId) continue;
6720
- out.push({ path: path14.join(dir, dateDir, file), date: dateDir, index: Number(match[2]) });
7839
+ out.push({ path: path15.join(dir, dateDir, file), date: dateDir, index: Number(match[2]) });
6721
7840
  }
6722
7841
  }
6723
7842
  return out.sort((a, b) => a.index - b.index);
@@ -6726,7 +7845,7 @@ var TraceService = class {
6726
7845
  async deleteSessionTraces(projectId, agentId, sessionId) {
6727
7846
  const files = await this.locateAll(projectId, agentId, sessionId);
6728
7847
  for (const file of files) {
6729
- await fs15.rm(file.path, { force: true });
7848
+ await fs16.rm(file.path, { force: true });
6730
7849
  }
6731
7850
  }
6732
7851
  /**
@@ -6764,7 +7883,7 @@ var TraceService = class {
6764
7883
  for (const agentId of await listDirs(agentsDir3(this.root, projectId))) {
6765
7884
  const dir = tracesDir3(this.root, projectId, agentId);
6766
7885
  for (const dateDir of await listDirs(dir)) {
6767
- for (const file of await listFiles(path14.join(dir, dateDir))) {
7886
+ for (const file of await listFiles(path15.join(dir, dateDir))) {
6768
7887
  const match = TRACE_FILE_RE2.exec(file);
6769
7888
  if (match && !index.has(match[1])) index.set(match[1], agentId);
6770
7889
  }
@@ -6807,7 +7926,7 @@ var TraceService = class {
6807
7926
  const files = await this.locateAll(projectId, agentId, sessionId);
6808
7927
  const out = [];
6809
7928
  for (const file of files) {
6810
- const stat = await fs15.stat(file.path);
7929
+ const stat = await fs16.stat(file.path);
6811
7930
  out.push({
6812
7931
  index: file.index,
6813
7932
  date: file.date,
@@ -6843,6 +7962,7 @@ var TraceService = class {
6843
7962
  let prevSerialTs = null;
6844
7963
  let taskIndex = -1;
6845
7964
  let continuation = false;
7965
+ let steeringImages = false;
6846
7966
  let sawToolCallThisRequest = false;
6847
7967
  let compactionActive = false;
6848
7968
  const taskStats = /* @__PURE__ */ new Map();
@@ -6870,7 +7990,10 @@ var TraceService = class {
6870
7990
  const hasOrigin = msg.origin !== void 0 && msg.origin.length > 0;
6871
7991
  const isSteeringText = !hasOrigin && msg.type === "model_msg" && p.type === "text" && p.role === "user" && typeof p.text === "string" && parseUserSteeringText(p.text) !== null;
6872
7992
  if (isSteeringText) continuation = true;
6873
- const startsUserTurn = !hasOrigin && !compactionActive && !isSteeringText && msg.type === "model_msg" && (p.type === "text" && p.role === "user" || p.type === "image_url");
7993
+ const isImage = !hasOrigin && msg.type === "model_msg" && p.type === "image_url";
7994
+ if (!hasOrigin && !isSteeringText && !(isImage && steeringImages)) steeringImages = false;
7995
+ if (isSteeringText) steeringImages = true;
7996
+ const startsUserTurn = !hasOrigin && !compactionActive && !isSteeringText && !steeringImages && msg.type === "model_msg" && (p.type === "text" && p.role === "user" || p.type === "image_url");
6874
7997
  const startsCompactionTurn = !hasOrigin && msg.type === "event_msg" && p.type === "compaction_begin";
6875
7998
  if (startsUserTurn || startsCompactionTurn) {
6876
7999
  if (pendingFrom === null) pendingFrom = mi;
@@ -6915,7 +8038,7 @@ var TraceService = class {
6915
8038
  }
6916
8039
  } else if (p.type === "request_end") {
6917
8040
  const status = typeof p.status === "string" ? p.status : void 0;
6918
- const retryable = status === "timeout" || status === "malformed";
8041
+ const retryable = status === "timeout" || status === "malformed" || status === "failed" && !compactionActive;
6919
8042
  if (!hasOrigin) {
6920
8043
  prevSerialTs = null;
6921
8044
  continuation = sawToolCallThisRequest || retryable;
@@ -7066,11 +8189,11 @@ var TraceService = class {
7066
8189
  const out = { dates: [] };
7067
8190
  for (const date of dates) {
7068
8191
  const bySession = /* @__PURE__ */ new Map();
7069
- for (const file of await listFiles(path14.join(dir, date))) {
8192
+ for (const file of await listFiles(path15.join(dir, date))) {
7070
8193
  const match = TRACE_FILE_RE2.exec(file);
7071
8194
  if (!match) continue;
7072
8195
  const sessionId = match[1];
7073
- const stat = await fs15.stat(path14.join(dir, date, file));
8196
+ const stat = await fs16.stat(path15.join(dir, date, file));
7074
8197
  const files = bySession.get(sessionId) ?? [];
7075
8198
  files.push({ index: Number(match[2]), sizeBytes: stat.size });
7076
8199
  bySession.set(sessionId, files);
@@ -7088,6 +8211,10 @@ var TraceService = class {
7088
8211
  return out;
7089
8212
  }
7090
8213
  async readFileByIndex(projectId, agentId, sessionId, index) {
8214
+ const file = await this.locateByIndex(projectId, agentId, sessionId, index);
8215
+ return readTraceTolerant2(file.path);
8216
+ }
8217
+ async locateByIndex(projectId, agentId, sessionId, index) {
7091
8218
  const files = await this.locateAll(projectId, agentId, sessionId);
7092
8219
  const file = files.find((f) => f.index === index);
7093
8220
  if (!file) {
@@ -7097,7 +8224,165 @@ var TraceService = class {
7097
8224
  `This Session has no Trace file with index ${index}.`
7098
8225
  );
7099
8226
  }
7100
- return readTraceTolerant2(file.path);
8227
+ return file;
8228
+ }
8229
+ /** Raw bytes of the Trace file at the given index (export/download: the file is served verbatim). */
8230
+ async readFileRaw(projectId, agentId, sessionId, index) {
8231
+ const file = await this.locateByIndex(projectId, agentId, sessionId, index);
8232
+ return fs16.readFile(file.path);
8233
+ }
8234
+ /**
8235
+ * Imports an uploaded Trace file (raw JSONL content). Validates the content itself —
8236
+ * parseable JSONL whose first record is a `session_meta` carrying a filename-safe
8237
+ * `session_id` (400 invalid_trace otherwise) — then writes it under the Agent's traces
8238
+ * directory as a **new Session**: a session id the Agent already has is rejected with
8239
+ * 409 `trace_session_exists` (splicing a further index into an existing Session would
8240
+ * corrupt its concatenated transcript, silently become its resume point, and could
8241
+ * collide with a live Writer's rotation), so the imported file is always index 1. The
8242
+ * date dir comes from the first record's timestamp (falling back to now), formatted as
8243
+ * a **local** date — the same convention as core's Trace Writer — so an export →
8244
+ * import round-trip lands in the same date dir on non-UTC servers.
8245
+ */
8246
+ async importTraceFile(projectId, agentId, content) {
8247
+ const invalid = (message) => new HttpError(400, "invalid_trace", message);
8248
+ let records;
8249
+ try {
8250
+ records = parseTraceLines2(content);
8251
+ } catch {
8252
+ throw invalid("The file is not valid Trace JSONL.");
8253
+ }
8254
+ if (records.length === 0) throw invalid("The file contains no Trace records.");
8255
+ const first = records[0];
8256
+ if (!isSessionMeta5(first))
8257
+ throw invalid("The first record of a Trace file must be session_meta.");
8258
+ const sessionId = first.payload.session_id;
8259
+ if (typeof sessionId !== "string" || !IMPORT_SESSION_ID_RE.test(sessionId)) {
8260
+ throw invalid("session_meta carries a missing or invalid session_id.");
8261
+ }
8262
+ const duplicate = () => new HttpError(
8263
+ 409,
8264
+ "trace_session_exists",
8265
+ `This Agent already has a Session with id ${sessionId}; a duplicate Trace cannot be imported.`
8266
+ );
8267
+ if ((await this.locateAll(projectId, agentId, sessionId)).length > 0) throw duplicate();
8268
+ const ts = Date.parse(first.timestamp);
8269
+ const date = formatLocalDate(Number.isNaN(ts) ? /* @__PURE__ */ new Date() : new Date(ts));
8270
+ const index = 1;
8271
+ const dir = path15.join(tracesDir3(this.root, projectId, agentId), date);
8272
+ await fs16.mkdir(dir, { recursive: true });
8273
+ const file = path15.join(dir, `${sessionId}_${String(index).padStart(3, "0")}.jsonl`);
8274
+ try {
8275
+ await fs16.writeFile(file, content.replace(/\n+$/, "") + "\n", {
8276
+ encoding: "utf8",
8277
+ flag: "wx"
8278
+ });
8279
+ } catch (err) {
8280
+ if (err.code === "EEXIST") throw duplicate();
8281
+ throw err;
8282
+ }
8283
+ return { sessionId, index, date };
8284
+ }
8285
+ };
8286
+
8287
+ // src/services/update-check-service.ts
8288
+ import { BUILD_DATE as BUILD_DATE2, VERSION as VERSION2, compareVersions, normalizeVersion } from "@prismshadow/penguin-core";
8289
+ var REPO_SLUG = "Prism-Shadow/penguin-harness";
8290
+ var LATEST_RELEASE_API = `https://api.github.com/repos/${REPO_SLUG}/releases/latest`;
8291
+ var SUCCESS_TTL_MS = 60 * 60 * 1e3;
8292
+ var FAILURE_TTL_MS = 10 * 60 * 1e3;
8293
+ var UpdateCheckService = class {
8294
+ fetchImpl;
8295
+ now;
8296
+ env;
8297
+ cached = null;
8298
+ inflight = null;
8299
+ constructor(options = {}) {
8300
+ this.fetchImpl = options.fetchImpl ?? fetch;
8301
+ this.now = options.now ?? (() => /* @__PURE__ */ new Date());
8302
+ this.env = options.env ?? process.env;
8303
+ }
8304
+ /**
8305
+ * Never throws; never makes a network call when disabled or (without `force`) while
8306
+ * the cache is fresh. `force` — the web's manual "check for updates" action — skips
8307
+ * the freshness check and performs a real lookup even over a warm cache: the cache
8308
+ * exists to shield GitHub's unauthenticated rate limit from *passive* checks fired
8309
+ * by every sidebar open, and an explicit user click is rare enough to press through
8310
+ * it. Everything else is unchanged: the opt-out stays authoritative (force never
8311
+ * dials out under PENGUIN_UPDATE_CHECK=off), the outcome lands in the same cache
8312
+ * (subsequent passive checks reuse it), and concurrent callers — forced or not —
8313
+ * still share one in-flight lookup.
8314
+ */
8315
+ async check(force = false) {
8316
+ if (this.env["PENGUIN_UPDATE_CHECK"] === "off") {
8317
+ return {
8318
+ currentVersion: VERSION2,
8319
+ buildDate: BUILD_DATE2,
8320
+ latestVersion: null,
8321
+ updateAvailable: false,
8322
+ releaseUrl: null,
8323
+ publishedAt: null,
8324
+ checkedAt: this.now().toISOString(),
8325
+ disabled: true
8326
+ };
8327
+ }
8328
+ if (!force && this.cached && this.now().getTime() < this.cached.expiresAt) {
8329
+ return this.cached.response;
8330
+ }
8331
+ this.inflight ??= this.lookup().finally(() => {
8332
+ this.inflight = null;
8333
+ });
8334
+ return this.inflight;
8335
+ }
8336
+ /** One real lookup; stores the outcome (success or failure) in the cache with its TTL. */
8337
+ async lookup() {
8338
+ const response = await this.resolveLatest();
8339
+ const ttl = response.error === void 0 ? SUCCESS_TTL_MS : FAILURE_TTL_MS;
8340
+ this.cached = { response, expiresAt: this.now().getTime() + ttl };
8341
+ return response;
8342
+ }
8343
+ /**
8344
+ * Failure taxonomy mirrors the CLI's fetchLatestVersion: an unreachable endpoint or
8345
+ * timeout is "network"; a 403/429 is "rate_limited" (unauthenticated clients hit
8346
+ * GitHub's per-IP limit); any other non-2xx status, an unparsable body, or a body
8347
+ * without a usable tag_name is "bad_response".
8348
+ */
8349
+ async resolveLatest() {
8350
+ const checkedAt = this.now().toISOString();
8351
+ const base = { currentVersion: VERSION2, buildDate: BUILD_DATE2, checkedAt };
8352
+ const failure = (error) => ({
8353
+ ...base,
8354
+ latestVersion: null,
8355
+ updateAvailable: false,
8356
+ releaseUrl: null,
8357
+ publishedAt: null,
8358
+ error
8359
+ });
8360
+ let res;
8361
+ try {
8362
+ res = await this.fetchImpl(LATEST_RELEASE_API, {
8363
+ headers: { accept: "application/vnd.github+json", "user-agent": "penguin-server" },
8364
+ signal: AbortSignal.timeout(15e3)
8365
+ });
8366
+ } catch {
8367
+ return failure("network");
8368
+ }
8369
+ if (res.status === 403 || res.status === 429) return failure("rate_limited");
8370
+ if (!res.ok) return failure("bad_response");
8371
+ let body;
8372
+ try {
8373
+ body = await res.json();
8374
+ } catch {
8375
+ return failure("bad_response");
8376
+ }
8377
+ const tag = typeof body.tag_name === "string" ? normalizeVersion(body.tag_name) : "";
8378
+ if (tag === "") return failure("bad_response");
8379
+ return {
8380
+ ...base,
8381
+ latestVersion: tag,
8382
+ updateAvailable: compareVersions(tag, VERSION2) > 0,
8383
+ releaseUrl: typeof body.html_url === "string" ? body.html_url : null,
8384
+ publishedAt: typeof body.published_at === "string" ? body.published_at : null
8385
+ };
7101
8386
  }
7102
8387
  };
7103
8388
 
@@ -7186,6 +8471,33 @@ var UsageService = class {
7186
8471
  models: this.usage.distinctModels(projectId)
7187
8472
  };
7188
8473
  }
8474
+ /**
8475
+ * One page of the error detail table, newest first. The dashboard's own response already
8476
+ * carries the first page (`errors.recent`); this serves the "show me earlier ones" paging,
8477
+ * where refetching the whole aggregate to move one page would be wasteful. `total` is the
8478
+ * filtered row count, so the caller knows when it has reached the end.
8479
+ *
8480
+ * Takes the same filter the dashboard applies — date + agent, and admin-only visibility of
8481
+ * unattributed errors — so a page never widens what the summary above it counted.
8482
+ *
8483
+ * Paging is by offset into a newest-first table that grows at its head, so errors recorded
8484
+ * between two page requests shift every row down and a page can re-show rows already seen.
8485
+ * Accepted deliberately: this is a diagnostic table read at a moment in time, not a feed to
8486
+ * be walked exhaustively, and keying off the newest row's id would cost a cursor the caller
8487
+ * has no other use for.
8488
+ */
8489
+ queryErrors(projectId, q) {
8490
+ const f = {
8491
+ ...q.agentId !== void 0 ? { agentId: q.agentId } : {},
8492
+ ...q.from !== void 0 ? { from: q.from } : {},
8493
+ ...q.to !== void 0 ? { to: q.to } : {},
8494
+ ...q.includeGlobalErrors === true ? { includeGlobal: true } : {}
8495
+ };
8496
+ return {
8497
+ items: this.errors.recent(projectId, f, q.limit, q.offset),
8498
+ total: this.errors.summary(projectId, f).total
8499
+ };
8500
+ }
7189
8501
  /** Error statistics: summary info (total / unexpected / most common error code) + the last N entries, all filtered by the selected range. */
7190
8502
  foldErrors(projectId, f) {
7191
8503
  const { total, unexpected } = this.errors.summary(projectId, f);
@@ -7270,10 +8582,10 @@ var UsageService = class {
7270
8582
  };
7271
8583
 
7272
8584
  // src/http/routes/preview.ts
7273
- import { Hono as Hono19 } from "hono";
8585
+ import { Hono as Hono20 } from "hono";
7274
8586
  var DIRECTORY_INDEX = "index.html";
7275
8587
  function previewRoutes(deps2) {
7276
- const app2 = new Hono19();
8588
+ const app2 = new Hono20();
7277
8589
  app2.get("/:token/*", async (c) => {
7278
8590
  const token = c.req.param("token");
7279
8591
  const payload = token ? deps2.previewTokens.verify(token) : null;
@@ -7331,13 +8643,14 @@ function buildAppDeps(config2, overrides = {}) {
7331
8643
  const errorsRepo = new ErrorsRepo(db);
7332
8644
  const prefsRepo = new UiPrefsRepo(db);
7333
8645
  const schedulesRepo = new SchedulesRepo(db);
8646
+ const goalsRepo = new GoalsRepo(db);
7334
8647
  const projectConfigService = new ProjectConfigService(config2.root);
7335
8648
  const agentConfigService = new AgentConfigService(config2.root);
7336
8649
  const agentService = new AgentService(config2.root, agentsRepo, agentConfigService);
7337
8650
  const traceService = new TraceService(config2.root);
7338
8651
  const workspaceFiles = new WorkspaceFilesService();
7339
8652
  const previewTokens = createPreviewTokenSigner();
7340
- const benchmarks = new BenchmarkService(config2.root);
8653
+ const benchmarks = new BenchmarkService(config2.root, workspaceFiles);
7341
8654
  const snapshots = new SnapshotService(config2.root);
7342
8655
  const usageService = new UsageService(
7343
8656
  usageRepo,
@@ -7345,6 +8658,7 @@ function buildAppDeps(config2, overrides = {}) {
7345
8658
  (projectId, provider, modelId) => projectConfigService.getPricing(projectId, provider, modelId),
7346
8659
  overrides.now ?? (() => /* @__PURE__ */ new Date())
7347
8660
  );
8661
+ const updateCheck = overrides.updateCheck ?? new UpdateCheckService(overrides.now ? { now: overrides.now } : {});
7348
8662
  let managerRef;
7349
8663
  const channels = new ChannelHub({
7350
8664
  isActive: (key) => managerRef !== void 0 && managerRef.statusOf(key) !== "idle"
@@ -7361,7 +8675,8 @@ function buildAppDeps(config2, overrides = {}) {
7361
8675
  recorder,
7362
8676
  errors,
7363
8677
  titles,
7364
- log
8678
+ log,
8679
+ goals: goalsRepo
7365
8680
  });
7366
8681
  managerRef = manager;
7367
8682
  const projectService = new ProjectService({
@@ -7374,6 +8689,7 @@ function buildAppDeps(config2, overrides = {}) {
7374
8689
  usage: usageRepo,
7375
8690
  errors: errorsRepo,
7376
8691
  schedules: schedulesRepo,
8692
+ goals: goalsRepo,
7377
8693
  projectConfig: projectConfigService,
7378
8694
  manager
7379
8695
  });
@@ -7426,11 +8742,14 @@ function buildAppDeps(config2, overrides = {}) {
7426
8742
  sessionService,
7427
8743
  traceService,
7428
8744
  usageService,
8745
+ updateCheck,
7429
8746
  workspaceFiles,
7430
8747
  previewTokens,
7431
8748
  benchmarks,
7432
8749
  snapshots,
7433
8750
  schedulesRepo,
8751
+ goalsRepo,
8752
+ errorsRepo,
7434
8753
  scheduler,
7435
8754
  channels,
7436
8755
  manager,
@@ -7440,7 +8759,7 @@ function buildAppDeps(config2, overrides = {}) {
7440
8759
  };
7441
8760
  }
7442
8761
  function createApp(deps2) {
7443
- const app2 = new Hono20();
8762
+ const app2 = new Hono21();
7444
8763
  app2.onError((err, c) => {
7445
8764
  const projectId = attributedProjectId(c, deps2);
7446
8765
  deps2.errors.record({
@@ -7472,18 +8791,23 @@ function createApp(deps2) {
7472
8791
  await next();
7473
8792
  });
7474
8793
  }
7475
- app2.use("/api/*", async (c, next) => {
7476
- const contentLength = Number(c.req.header("content-length") ?? 0);
7477
- if (contentLength > MAX_BODY_BYTES) {
7478
- throw new HttpError(413, "payload_too_large", "Request body exceeds the 20MB limit.");
7479
- }
7480
- await next();
7481
- });
8794
+ app2.use(
8795
+ "/api/*",
8796
+ bodyLimit({
8797
+ maxSize: MAX_BODY_BYTES,
8798
+ // Its default is a bare text/plain 413; throw the App's own error instead so the response
8799
+ // stays the documented `payload_too_large` body that every client already handles.
8800
+ onError: () => {
8801
+ throw new HttpError(413, "payload_too_large", "Request body exceeds the 20MB limit.");
8802
+ }
8803
+ })
8804
+ );
7482
8805
  app2.use("/api/*", jsonOnlyWrites);
7483
8806
  app2.route("/api/auth", authRoutes(deps2));
7484
8807
  const auth = authMiddleware(deps2.authService);
7485
8808
  app2.use("/api/*", auth);
7486
8809
  app2.route("/api/me", meRoutes(deps2));
8810
+ app2.route("/api/version", versionRoutes(deps2));
7487
8811
  app2.route("/api/admin/users", adminUsersRoutes(deps2));
7488
8812
  app2.route("/api/events", eventsRoutes(deps2));
7489
8813
  app2.route("/api/skills", skillLibraryRoutes());
@@ -7503,7 +8827,7 @@ function createApp(deps2) {
7503
8827
  app2.route("/api/projects/:projectId/usage", usageRoutes(deps2));
7504
8828
  app2.route("/api/sessions", sessionsRoutes(deps2));
7505
8829
  app2.route("/preview", previewRoutes(deps2));
7506
- if (fs16.existsSync(deps2.config.webDist)) {
8830
+ if (fs17.existsSync(deps2.config.webDist)) {
7507
8831
  registerStaticRoutes(app2, deps2.config.webDist);
7508
8832
  }
7509
8833
  return app2;
@@ -7539,16 +8863,16 @@ function registerStaticRoutes(app2, webDist) {
7539
8863
  return c.json(errorBody("not_found", "Endpoint does not exist."), 404);
7540
8864
  }
7541
8865
  const rel = reqPath.replace(/^\/+/, "");
7542
- const resolved = path15.resolve(webDist, rel === "" ? "index.html" : rel);
7543
- const base = path15.resolve(webDist);
7544
- const target = resolved === base || resolved.startsWith(base + path15.sep) ? resolved : path15.join(base, "index.html");
8866
+ const resolved = path16.resolve(webDist, rel === "" ? "index.html" : rel);
8867
+ const base = path16.resolve(webDist);
8868
+ const target = resolved === base || resolved.startsWith(base + path16.sep) ? resolved : path16.join(base, "index.html");
7545
8869
  let file = target;
7546
8870
  try {
7547
8871
  const stat = await fsp.stat(file);
7548
- if (stat.isDirectory()) file = path15.join(file, "index.html");
8872
+ if (stat.isDirectory()) file = path16.join(file, "index.html");
7549
8873
  await fsp.access(file);
7550
8874
  } catch {
7551
- file = path15.join(base, "index.html");
8875
+ file = path16.join(base, "index.html");
7552
8876
  }
7553
8877
  let content;
7554
8878
  try {
@@ -7556,7 +8880,7 @@ function registerStaticRoutes(app2, webDist) {
7556
8880
  } catch {
7557
8881
  return c.json(errorBody("not_found", "Resource does not exist."), 404);
7558
8882
  }
7559
- const type = CONTENT_TYPES2[path15.extname(file).toLowerCase()] ?? "application/octet-stream";
8883
+ const type = CONTENT_TYPES2[path16.extname(file).toLowerCase()] ?? "application/octet-stream";
7560
8884
  return new Response(new Uint8Array(content), {
7561
8885
  status: 200,
7562
8886
  headers: { "Content-Type": type }
@@ -7565,16 +8889,16 @@ function registerStaticRoutes(app2, webDist) {
7565
8889
  }
7566
8890
 
7567
8891
  // src/config.ts
7568
- import fs17 from "fs";
7569
- import path16 from "path";
8892
+ import fs18 from "fs";
8893
+ import path17 from "path";
7570
8894
  import { fileURLToPath } from "url";
7571
8895
  import { DEFAULT_SERVER_PORT, resolveRoot } from "@prismshadow/penguin-core";
7572
8896
  var DAY_MS = 24 * 60 * 60 * 1e3;
7573
8897
  function defaultWebDist() {
7574
- const here = path16.dirname(fileURLToPath(import.meta.url));
7575
- const bundled = path16.resolve(here, "..", "web-dist");
7576
- if (fs17.existsSync(bundled)) return bundled;
7577
- return path16.resolve(here, "..", "..", "web", "dist");
8898
+ const here = path17.dirname(fileURLToPath(import.meta.url));
8899
+ const bundled = path17.resolve(here, "..", "web-dist");
8900
+ if (fs18.existsSync(bundled)) return bundled;
8901
+ return path17.resolve(here, "..", "..", "web", "dist");
7578
8902
  }
7579
8903
  function normalizePreviewOrigin(raw) {
7580
8904
  if (!raw || raw.trim() === "") return null;
@@ -7599,7 +8923,7 @@ function resolveServerConfig(env = process.env) {
7599
8923
  root,
7600
8924
  host: env.HOST ?? "127.0.0.1",
7601
8925
  port,
7602
- dbPath: env.PENGUIN_WEB_DB ?? path16.join(root, "web.db"),
8926
+ dbPath: env.PENGUIN_WEB_DB ?? path17.join(root, "web.db"),
7603
8927
  webDist: env.PENGUIN_WEB_DIST ?? defaultWebDist(),
7604
8928
  previewOrigin: normalizePreviewOrigin(env.PENGUIN_PREVIEW_ORIGIN),
7605
8929
  authSessionTtlMs: 7 * DAY_MS,
@@ -7614,6 +8938,7 @@ var deps = buildAppDeps(config);
7614
8938
  var app = createApp(deps);
7615
8939
  await deps.authService.seedAdmin();
7616
8940
  await deps.scheduler.start();
8941
+ deps.goalsRepo.abortOrphanedActive();
7617
8942
  var appHost = loopbackHostRoles(config.host)?.app ?? config.host;
7618
8943
  var server = serve({ fetch: app.fetch, hostname: config.host, port: config.port }, (info) => {
7619
8944
  console.log(`penguin-server started: http://${appHost}:${info.port}`);