@prismshadow/penguin-server 0.1.1 → 0.1.4

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
@@ -6,7 +6,7 @@ import { serve } from "@hono/node-server";
6
6
  import fs16 from "fs";
7
7
  import fsp from "fs/promises";
8
8
  import path15 from "path";
9
- import { Hono as Hono19 } from "hono";
9
+ import { Hono as Hono21 } from "hono";
10
10
 
11
11
  // src/db/database.ts
12
12
  import { mkdirSync } from "fs";
@@ -102,6 +102,20 @@ CREATE TABLE IF NOT EXISTS schedule_state ( -- schedule runtime state (files
102
102
  invalid_reason TEXT, -- invalidation reason (e.g. the bound Session was deleted); NULL=healthy
103
103
  PRIMARY KEY (project_id, agent_id, name)
104
104
  );
105
+ 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)
106
+ id INTEGER PRIMARY KEY AUTOINCREMENT, -- one row per goal run; a Session may run goals repeatedly, latest row wins for display
107
+ session_id TEXT NOT NULL,
108
+ project_id TEXT NOT NULL,
109
+ agent_id TEXT NOT NULL,
110
+ objective TEXT NOT NULL,
111
+ status TEXT NOT NULL, -- active | complete | blocked | budget_limited | aborted (aborted is server-side only; on-disk status stays active)
112
+ budget INTEGER NOT NULL, -- token budget; -1 = unlimited
113
+ used INTEGER NOT NULL DEFAULT 0, -- uncached input + output, refreshed per round (mirrors the runner's accounting)
114
+ rounds INTEGER NOT NULL DEFAULT 0,
115
+ created_at TEXT NOT NULL,
116
+ updated_at TEXT NOT NULL
117
+ );
118
+ CREATE INDEX IF NOT EXISTS idx_goal_session ON goal_state(session_id);
105
119
  CREATE TABLE IF NOT EXISTS ui_prefs (
106
120
  user_id TEXT PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
107
121
  prefs_json TEXT NOT NULL -- {theme?, lastProjectId?, ...} free-form JSON
@@ -300,6 +314,10 @@ var ErrorsRepo = class {
300
314
  message: r.message
301
315
  }));
302
316
  }
317
+ /** Cascading cleanup on Agent deletion (unattributed errors carry no agent_id and are unaffected). */
318
+ deleteByAgent(projectId, agentId) {
319
+ this.db.prepare("DELETE FROM error_records WHERE project_id = ? AND agent_id = ?").run(projectId, agentId);
320
+ }
303
321
  /** Cascading cleanup on Project deletion (unattributed errors belong to no Project and are unaffected). */
304
322
  deleteByProject(projectId) {
305
323
  this.db.prepare("DELETE FROM error_records WHERE project_id = ?").run(projectId);
@@ -385,8 +403,77 @@ var ProjectsRepo = class {
385
403
  }
386
404
  };
387
405
 
388
- // src/db/repos/schedules.ts
406
+ // src/db/repos/goals.ts
389
407
  function mapRow2(r) {
408
+ return {
409
+ id: Number(r.id),
410
+ sessionId: r.session_id,
411
+ projectId: r.project_id,
412
+ agentId: r.agent_id,
413
+ objective: r.objective,
414
+ status: r.status,
415
+ budget: Number(r.budget),
416
+ used: Number(r.used),
417
+ rounds: Number(r.rounds),
418
+ createdAt: r.created_at,
419
+ updatedAt: r.updated_at
420
+ };
421
+ }
422
+ var GoalsRepo = class {
423
+ constructor(db) {
424
+ this.db = db;
425
+ }
426
+ db;
427
+ /** Register a new goal run (status active, counters at zero); returns the new row id. */
428
+ create(args) {
429
+ const now = (/* @__PURE__ */ new Date()).toISOString();
430
+ const result = this.db.prepare(
431
+ `INSERT INTO goal_state
432
+ (session_id, project_id, agent_id, objective, status, budget, used, rounds, created_at, updated_at)
433
+ VALUES (?, ?, ?, ?, 'active', ?, 0, 0, ?, ?)`
434
+ ).run(args.sessionId, args.projectId, args.agentId, args.objective, args.budget, now, now);
435
+ return Number(result.lastInsertRowid);
436
+ }
437
+ /** Per-round progress refresh (round just started; `used` mirrors the runner's accounting so far). */
438
+ progress(id, rounds, used) {
439
+ this.db.prepare("UPDATE goal_state SET rounds = ?, used = ?, updated_at = ? WHERE id = ?").run(rounds, used, (/* @__PURE__ */ new Date()).toISOString(), id);
440
+ }
441
+ /** Record the terminal outcome. */
442
+ finish(id, status, rounds, used) {
443
+ this.db.prepare(
444
+ "UPDATE goal_state SET status = ?, rounds = ?, used = ?, updated_at = ? WHERE id = ?"
445
+ ).run(status, rounds, used, (/* @__PURE__ */ new Date()).toISOString(), id);
446
+ }
447
+ /** The Session's most recent goal run (what the chat page's banner restores from); null if it never ran one. */
448
+ latestForSession(sessionId) {
449
+ const r = this.db.prepare("SELECT * FROM goal_state WHERE session_id = ? ORDER BY id DESC LIMIT 1").get(sessionId);
450
+ return r ? mapRow2(r) : null;
451
+ }
452
+ deleteBySession(sessionId) {
453
+ this.db.prepare("DELETE FROM goal_state WHERE session_id = ?").run(sessionId);
454
+ }
455
+ deleteByAgent(projectId, agentId) {
456
+ this.db.prepare("DELETE FROM goal_state WHERE project_id = ? AND agent_id = ?").run(projectId, agentId);
457
+ }
458
+ deleteByProject(projectId) {
459
+ this.db.prepare("DELETE FROM goal_state WHERE project_id = ?").run(projectId);
460
+ }
461
+ /**
462
+ * Startup reconciliation: a goal runs only in SessionManager memory, so a hard crash
463
+ * (SIGKILL, power loss) leaves its row stuck `active` with no runner behind it — the banner
464
+ * would then restore a forever-"running" goal. Called once at boot, before the server accepts
465
+ * connections (nothing can be running yet), so every remaining `active` row is an orphan:
466
+ * mark them `aborted`. The on-disk GOAL.yaml is left `active` as the documented resume point.
467
+ * Returns the number of rows reconciled.
468
+ */
469
+ abortOrphanedActive() {
470
+ const result = this.db.prepare("UPDATE goal_state SET status = 'aborted', updated_at = ? WHERE status = 'active'").run((/* @__PURE__ */ new Date()).toISOString());
471
+ return Number(result.changes);
472
+ }
473
+ };
474
+
475
+ // src/db/repos/schedules.ts
476
+ function mapRow3(r) {
390
477
  return {
391
478
  projectId: r.project_id,
392
479
  agentId: r.agent_id,
@@ -408,11 +495,11 @@ var SchedulesRepo = class {
408
495
  db;
409
496
  find(projectId, agentId, name) {
410
497
  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;
498
+ return r ? mapRow3(r) : null;
412
499
  }
413
500
  listByAgent(projectId, agentId) {
414
501
  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));
502
+ return rows.map((r) => mapRow3(r));
416
503
  }
417
504
  /**
418
505
  * Register or sync a task's runtime state, returning the synced row plus a `fresh`
@@ -507,13 +594,16 @@ var SchedulesRepo = class {
507
594
  }
508
595
  return removed;
509
596
  }
597
+ deleteByAgent(projectId, agentId) {
598
+ this.db.prepare("DELETE FROM schedule_state WHERE project_id = ? AND agent_id = ?").run(projectId, agentId);
599
+ }
510
600
  deleteByProject(projectId) {
511
601
  this.db.prepare("DELETE FROM schedule_state WHERE project_id = ?").run(projectId);
512
602
  }
513
603
  };
514
604
 
515
605
  // src/db/repos/sessions.ts
516
- function mapRow3(r) {
606
+ function mapRow4(r) {
517
607
  return {
518
608
  sessionId: r.session_id,
519
609
  projectId: r.project_id,
@@ -567,15 +657,15 @@ var SessionsRepo = class {
567
657
  }
568
658
  findById(sessionId) {
569
659
  const r = this.db.prepare("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
570
- return r ? mapRow3(r) : null;
660
+ return r ? mapRow4(r) : null;
571
661
  }
572
662
  listByAgent(projectId, agentId) {
573
663
  const rows = this.db.prepare("SELECT * FROM sessions WHERE project_id = ? AND agent_id = ?").all(projectId, agentId);
574
- return rows.map(mapRow3);
664
+ return rows.map(mapRow4);
575
665
  }
576
666
  listByProject(projectId) {
577
667
  const rows = this.db.prepare("SELECT * FROM sessions WHERE project_id = ?").all(projectId);
578
- return rows.map(mapRow3);
668
+ return rows.map(mapRow4);
579
669
  }
580
670
  updateApprovalMode(sessionId, mode) {
581
671
  this.db.prepare("UPDATE sessions SET approval_mode = ? WHERE session_id = ?").run(mode, sessionId);
@@ -780,7 +870,7 @@ var UsageRepo = class {
780
870
  };
781
871
 
782
872
  // src/db/repos/users.ts
783
- function mapRow4(r) {
873
+ function mapRow5(r) {
784
874
  return {
785
875
  userId: r.user_id,
786
876
  passwordHash: r.password_hash,
@@ -807,12 +897,12 @@ var UsersRepo = class {
807
897
  }
808
898
  findById(userId) {
809
899
  const r = this.db.prepare("SELECT * FROM users WHERE user_id = ?").get(userId);
810
- return r ? mapRow4(r) : null;
900
+ return r ? mapRow5(r) : null;
811
901
  }
812
902
  /** All users (for the admin user backend), ordered by creation time ascending. */
813
903
  list() {
814
904
  const rows = this.db.prepare("SELECT * FROM users ORDER BY created_at ASC, user_id ASC").all();
815
- return rows.map(mapRow4);
905
+ return rows.map(mapRow5);
816
906
  }
817
907
  count() {
818
908
  const r = this.db.prepare("SELECT COUNT(*) AS n FROM users").get();
@@ -1154,6 +1244,12 @@ function optionalNumber(obj, key, rule = {}) {
1154
1244
  if (rule.nonNegative && v < 0) throw badRequest(`${label} must not be negative.`);
1155
1245
  return v;
1156
1246
  }
1247
+ function optionalBoolean(obj, key, label = key) {
1248
+ const v = obj[key];
1249
+ if (v === void 0) return void 0;
1250
+ if (typeof v !== "boolean") throw badRequest(`${label} must be a boolean.`);
1251
+ return v;
1252
+ }
1157
1253
  function optionalDateParam(value, label) {
1158
1254
  if (value === void 0 || value === "") return void 0;
1159
1255
  const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
@@ -1233,10 +1329,111 @@ function authRoutes(deps2) {
1233
1329
 
1234
1330
  // src/http/routes/me.ts
1235
1331
  import { Hono as Hono3 } from "hono";
1332
+
1333
+ // src/services/preview-token.ts
1334
+ import { createHmac, randomBytes as randomBytes3, timingSafeEqual as timingSafeEqual2 } from "crypto";
1335
+ var PREVIEW_TOKEN_TTL_MS = 10 * 60 * 1e3;
1336
+ function b64url(buf) {
1337
+ return buf.toString("base64url");
1338
+ }
1339
+ function createPreviewTokenSigner(secret = randomBytes3(32)) {
1340
+ const mac = (body) => createHmac("sha256", secret).update(body).digest();
1341
+ return {
1342
+ sign(payload) {
1343
+ const body = b64url(Buffer.from(JSON.stringify(payload), "utf8"));
1344
+ return `${body}.${b64url(mac(body))}`;
1345
+ },
1346
+ verify(token) {
1347
+ const dot = token.indexOf(".");
1348
+ if (dot <= 0 || dot === token.length - 1) return null;
1349
+ const body = token.slice(0, dot);
1350
+ const provided = Buffer.from(token.slice(dot + 1), "base64url");
1351
+ const expected = mac(body);
1352
+ if (provided.length !== expected.length || !timingSafeEqual2(provided, expected)) return null;
1353
+ let payload;
1354
+ try {
1355
+ payload = JSON.parse(
1356
+ Buffer.from(body, "base64url").toString("utf8")
1357
+ );
1358
+ } catch {
1359
+ return null;
1360
+ }
1361
+ if (typeof payload?.sessionId !== "string" || typeof payload?.host !== "string" || typeof payload?.expiresAt !== "number") {
1362
+ return null;
1363
+ }
1364
+ if (Date.now() >= payload.expiresAt) return null;
1365
+ return payload;
1366
+ }
1367
+ };
1368
+ }
1369
+ function hostOnly(hostHeader) {
1370
+ const trimmed = hostHeader.trim();
1371
+ if (trimmed.startsWith("[")) {
1372
+ const end = trimmed.indexOf("]");
1373
+ return end === -1 ? trimmed : trimmed.slice(0, end + 1);
1374
+ }
1375
+ const colon = trimmed.lastIndexOf(":");
1376
+ return colon === -1 ? trimmed : trimmed.slice(0, colon);
1377
+ }
1378
+ function loopbackCounterpart(host) {
1379
+ const h = host.toLowerCase();
1380
+ if (h === "127.0.0.1") return "localhost";
1381
+ if (h === "localhost") return "127.0.0.1";
1382
+ if (h === "[::1]") return "127.0.0.1";
1383
+ return null;
1384
+ }
1385
+ function loopbackHostRoles(bindHost) {
1386
+ const h = bindHost.trim().toLowerCase();
1387
+ if (h !== "127.0.0.1" && h !== "localhost") return null;
1388
+ const app2 = "localhost";
1389
+ const preview = loopbackCounterpart(app2);
1390
+ if (!preview) return null;
1391
+ return { app: app2, preview };
1392
+ }
1393
+ function requestAuthority(requestUrl, hostHeader) {
1394
+ const header = (hostHeader ?? "").trim();
1395
+ if (header !== "") return header;
1396
+ try {
1397
+ return new URL(requestUrl).host;
1398
+ } catch {
1399
+ return "";
1400
+ }
1401
+ }
1402
+ function resolvePreviewTarget(requestUrl, hostHeader, configuredOrigin, serverBind) {
1403
+ const requestHost = hostOnly(requestAuthority(requestUrl, hostHeader)).toLowerCase();
1404
+ if (configuredOrigin) {
1405
+ const url = new URL(configuredOrigin);
1406
+ if (url.hostname.toLowerCase() === requestHost) return null;
1407
+ return { origin: url.origin, host: url.hostname };
1408
+ }
1409
+ if (requestHost === "") return null;
1410
+ const counterpart = loopbackCounterpart(requestHost);
1411
+ if (!counterpart) return null;
1412
+ if (!loopbackHostRoles(serverBind.host)) return null;
1413
+ let protocol;
1414
+ try {
1415
+ protocol = new URL(requestUrl).protocol;
1416
+ } catch {
1417
+ protocol = "http:";
1418
+ }
1419
+ const suffix = protocol === "http:" && serverBind.port === 80 || protocol === "https:" && serverBind.port === 443 ? "" : `:${serverBind.port}`;
1420
+ return { origin: `${protocol}//${counterpart}${suffix}`, host: counterpart };
1421
+ }
1422
+
1423
+ // src/http/routes/me.ts
1236
1424
  function meRoutes(deps2) {
1237
1425
  const app2 = new Hono3();
1238
1426
  app2.get("/", (c) => {
1239
- return c.json({ user: toUserInfo(c.var.user) });
1427
+ const target = resolvePreviewTarget(
1428
+ c.req.url,
1429
+ c.req.header("host"),
1430
+ deps2.config.previewOrigin,
1431
+ deps2.config
1432
+ );
1433
+ return c.json({
1434
+ user: toUserInfo(c.var.user),
1435
+ previewIsolated: target !== null
1436
+ });
1240
1437
  });
1241
1438
  app2.put("/password", async (c) => {
1242
1439
  const body = await readJson(c);
@@ -1407,6 +1604,12 @@ function membersRoutes(deps2) {
1407
1604
 
1408
1605
  // src/http/routes/models.ts
1409
1606
  import { Hono as Hono7 } from "hono";
1607
+ function publishCredentialsUpdated(deps2, projectId) {
1608
+ const event = { type: "credentials_updated" };
1609
+ for (const row of deps2.sessionsRepo.listByProject(projectId)) {
1610
+ deps2.channels.peek(row.sessionId)?.publish(event, "server_event");
1611
+ }
1612
+ }
1410
1613
  function parseRef(value, label) {
1411
1614
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
1412
1615
  throw badRequest(`${label} must be a { provider, modelId } object.`);
@@ -1526,7 +1729,10 @@ function modelsRoutes(deps2) {
1526
1729
  const projectId = requireValidId(c, "projectId");
1527
1730
  deps2.projectService.requireProjectOwner(c.var.user.userId, projectId);
1528
1731
  const req = parseModelsUpdate(await readJson(c));
1529
- return c.json(await deps2.projectConfigService.updateModels(projectId, req));
1732
+ const res = await deps2.projectConfigService.updateModels(projectId, req);
1733
+ deps2.manager.invalidateProjectRuntimes(projectId);
1734
+ publishCredentialsUpdated(deps2, projectId);
1735
+ return c.json(res);
1530
1736
  });
1531
1737
  app2.post("/test", async (c) => {
1532
1738
  const projectId = requireValidId(c, "projectId");
@@ -2198,6 +2404,9 @@ function agentsRoutes(deps2) {
2198
2404
  await settleWithin(runnings, 5e3);
2199
2405
  await deps2.agentService.deleteAgent(projectId, agentId);
2200
2406
  deps2.sessionsRepo.deleteByAgent(projectId, agentId);
2407
+ deps2.goalsRepo.deleteByAgent(projectId, agentId);
2408
+ deps2.schedulesRepo.deleteByAgent(projectId, agentId);
2409
+ deps2.errorsRepo.deleteByAgent(projectId, agentId);
2201
2410
  } finally {
2202
2411
  deps2.manager.endAgentDeletion(projectId, agentId);
2203
2412
  }
@@ -2287,11 +2496,23 @@ function agentConfigRoutes(deps2) {
2287
2496
  activeSessionCount: deps2.manager.activeCountForAgent(projectId, agentId)
2288
2497
  });
2289
2498
  });
2499
+ app2.post("/reset", async (c) => {
2500
+ const projectId = requireValidId(c, "projectId");
2501
+ const agentId = requireValidId(c, "agentId");
2502
+ deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
2503
+ await deps2.agentConfigService.resetConfig(projectId, agentId);
2504
+ const view = await deps2.agentConfigService.getConfig(projectId, agentId);
2505
+ return c.json({
2506
+ ...view,
2507
+ activeSessionCount: deps2.manager.activeCountForAgent(projectId, agentId)
2508
+ });
2509
+ });
2290
2510
  return app2;
2291
2511
  }
2292
2512
 
2293
2513
  // src/http/routes/agent-traces.ts
2294
2514
  import { Hono as Hono16 } from "hono";
2515
+ var MAX_TRACE_BYTES = 14 * 1024 * 1024;
2295
2516
  function agentTracesRoutes(deps2) {
2296
2517
  const app2 = new Hono16();
2297
2518
  app2.get("/", async (c) => {
@@ -2319,6 +2540,41 @@ function agentTracesRoutes(deps2) {
2319
2540
  const index = positiveIntParam(c, "index");
2320
2541
  return c.json(await deps2.traceService.analyze(projectId, agentId, sessionId, index));
2321
2542
  });
2543
+ app2.get("/:sessionId/:index/download", async (c) => {
2544
+ const projectId = requireValidId(c, "projectId");
2545
+ const agentId = requireValidId(c, "agentId");
2546
+ const sessionId = requireValidId(c, "sessionId");
2547
+ deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
2548
+ const index = positiveIntParam(c, "index");
2549
+ const bytes = await deps2.traceService.readFileRaw(projectId, agentId, sessionId, index);
2550
+ const fileName = `${sessionId}_${String(index).padStart(3, "0")}.jsonl`;
2551
+ return new Response(new Uint8Array(bytes), {
2552
+ headers: {
2553
+ "Content-Type": "application/x-ndjson",
2554
+ "Content-Disposition": `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`,
2555
+ "X-Content-Type-Options": "nosniff"
2556
+ }
2557
+ });
2558
+ });
2559
+ app2.post("/import", async (c) => {
2560
+ const projectId = requireValidId(c, "projectId");
2561
+ const agentId = requireValidId(c, "agentId");
2562
+ deps2.projectService.requireProjectOwner(c.var.user.userId, projectId);
2563
+ await deps2.agentConfigService.requireExists(projectId, agentId);
2564
+ const body = await readJson(c);
2565
+ const dataBase64 = requireString(body, "dataBase64", { minLen: 1, maxLen: 20 * 1024 * 1024 });
2566
+ const bytes = Buffer.from(dataBase64, "base64");
2567
+ if (bytes.byteLength === 0) throw badRequest("Import file is empty.");
2568
+ if (bytes.byteLength > MAX_TRACE_BYTES) {
2569
+ throw badRequest("Import file exceeds the 14MB limit.");
2570
+ }
2571
+ const res = await deps2.traceService.importTraceFile(
2572
+ projectId,
2573
+ agentId,
2574
+ bytes.toString("utf8")
2575
+ );
2576
+ return c.json(res);
2577
+ });
2322
2578
  return app2;
2323
2579
  }
2324
2580
 
@@ -2617,6 +2873,10 @@ var WorkspaceFilesService = class {
2617
2873
  }
2618
2874
  const { dir, name } = await this.resolveWriteParent(workspace, rel);
2619
2875
  const file = path5.join(dir, name);
2876
+ if (process.platform === "win32") {
2877
+ const st = await fs6.lstat(file).catch(() => null);
2878
+ if (st?.isSymbolicLink()) throw badRequest("path must not be a symlink.");
2879
+ }
2620
2880
  const flags = fsc.O_WRONLY | fsc.O_CREAT | fsc.O_TRUNC | (fsc.O_NOFOLLOW ?? 0);
2621
2881
  let handle;
2622
2882
  try {
@@ -2647,6 +2907,13 @@ var APPROVAL_MODES = [
2647
2907
  "read-only",
2648
2908
  "always-ask"
2649
2909
  ];
2910
+ var THINKING_LEVELS = ["none", "low", "medium", "high", "xhigh"];
2911
+ var SESSION_CATEGORIES = [
2912
+ "active",
2913
+ "subagent",
2914
+ "schedule",
2915
+ "archived"
2916
+ ];
2650
2917
  function parseTaskInput(body) {
2651
2918
  const input = body.input;
2652
2919
  if (!Array.isArray(input) || input.length === 0) {
@@ -2673,6 +2940,18 @@ function parseTaskInput(body) {
2673
2940
  throw badRequest(`input[${i}].type must be one of text / image_url.`);
2674
2941
  });
2675
2942
  }
2943
+ function parseGoalField(body) {
2944
+ const goal = body.goal;
2945
+ if (goal === void 0) return null;
2946
+ if (goal === null || typeof goal !== "object" || Array.isArray(goal)) {
2947
+ throw badRequest("goal must be an object.");
2948
+ }
2949
+ const budget = goal.budget;
2950
+ if (budget !== void 0 && (typeof budget !== "number" || !Number.isInteger(budget) || budget <= 0 && budget !== -1)) {
2951
+ throw badRequest("goal.budget must be a positive integer, or -1 for unlimited.");
2952
+ }
2953
+ return { budget: budget ?? -1 };
2954
+ }
2676
2955
  function agentSessionsRoutes(deps2) {
2677
2956
  const app2 = new Hono18();
2678
2957
  app2.get("/", async (c) => {
@@ -2681,12 +2960,26 @@ function agentSessionsRoutes(deps2) {
2681
2960
  deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
2682
2961
  await deps2.agentConfigService.requireExists(projectId, agentId);
2683
2962
  const paging = optionalPagingQuery(c);
2684
- const sessions = await deps2.sessionService.listSessions(
2963
+ const rawCategory = c.req.query("category");
2964
+ if (rawCategory !== void 0 && !SESSION_CATEGORIES.includes(rawCategory)) {
2965
+ throw badRequest(`category must be one of ${SESSION_CATEGORIES.join(" / ")}.`);
2966
+ }
2967
+ const rawCounts = c.req.query("counts");
2968
+ if (rawCounts !== void 0 && rawCounts !== "1") throw badRequest("counts only accepts 1.");
2969
+ const { sessions, counts, workspaceCounts } = await deps2.sessionService.listSessions(
2685
2970
  projectId,
2686
2971
  agentId,
2687
- ...paging ? [paging] : []
2972
+ {
2973
+ ...paging ? { paging } : {},
2974
+ ...rawCategory !== void 0 ? { category: rawCategory } : {},
2975
+ ...rawCounts !== void 0 ? { withCounts: true } : {}
2976
+ }
2688
2977
  );
2689
- return c.json({ sessions });
2978
+ return c.json({
2979
+ sessions,
2980
+ ...counts ? { counts } : {},
2981
+ ...workspaceCounts ? { workspaceCounts } : {}
2982
+ });
2690
2983
  });
2691
2984
  app2.post("/", async (c) => {
2692
2985
  const projectId = requireValidId(c, "projectId");
@@ -2744,8 +3037,10 @@ function sessionsRoutes(deps2) {
2744
3037
  app2.get("/:sessionId", async (c) => {
2745
3038
  const row = resolveSession(c);
2746
3039
  const hasTrace = await deps2.sessionService.hasTrace(row);
3040
+ const info = await deps2.sessionService.toInfo(row, hasTrace);
3041
+ const tracePath = hasTrace ? await deps2.sessionService.latestTracePath(row) : void 0;
2747
3042
  return c.json({
2748
- session: await deps2.sessionService.toInfo(row, hasTrace)
3043
+ session: { ...info, ...tracePath !== void 0 ? { tracePath } : {} }
2749
3044
  });
2750
3045
  });
2751
3046
  app2.patch("/:sessionId", async (c) => {
@@ -2811,6 +3106,7 @@ function sessionsRoutes(deps2) {
2811
3106
  { recursive: true, force: true }
2812
3107
  );
2813
3108
  deps2.sessionsRepo.deleteById(row.sessionId);
3109
+ deps2.goalsRepo.deleteBySession(row.sessionId);
2814
3110
  deps2.sessionSources.delete(row.sessionId);
2815
3111
  } finally {
2816
3112
  deps2.manager.endSessionDeletion(row.sessionId);
@@ -2849,18 +3145,29 @@ function sessionsRoutes(deps2) {
2849
3145
  });
2850
3146
  app2.get("/:sessionId/messages", async (c) => {
2851
3147
  const row = resolveSession(c);
3148
+ let live;
3149
+ if (deps2.manager.statusOf(row.sessionId) !== "idle") {
3150
+ live = {
3151
+ cursor: deps2.channels.get(row.sessionId).lastEventId,
3152
+ fragments: deps2.manager.liveFragments(row.sessionId)
3153
+ };
3154
+ }
2852
3155
  const messages = await deps2.traceService.readMessages(
2853
3156
  row.projectId,
2854
3157
  row.agentId,
2855
3158
  row.sessionId
2856
3159
  );
2857
- return c.json({ messages });
3160
+ return c.json({ messages, ...live !== void 0 ? { live } : {} });
2858
3161
  });
2859
3162
  app2.get("/:sessionId/stream", (c) => {
2860
3163
  const row = resolveSession(c);
2861
3164
  const channel = deps2.channels.get(row.sessionId);
2862
3165
  const initialEvents = [
2863
- { type: "task_state", state: deps2.manager.statusOf(row.sessionId) },
3166
+ {
3167
+ type: "task_state",
3168
+ state: deps2.manager.statusOf(row.sessionId),
3169
+ queued: deps2.manager.pendingFollowUpCount(row.sessionId)
3170
+ },
2864
3171
  ...deps2.manager.pendingApprovals(row.sessionId).map((p) => ({
2865
3172
  type: "approval_request",
2866
3173
  toolCall: p.toolCall,
@@ -2871,9 +3178,51 @@ function sessionsRoutes(deps2) {
2871
3178
  });
2872
3179
  app2.post("/:sessionId/tasks", async (c) => {
2873
3180
  const row = resolveSession(c);
2874
- const input = parseTaskInput(await readJson(c));
2875
- const { sessionId } = await deps2.manager.startTask(row.sessionId, input);
2876
- return c.json({ sessionId }, 202);
3181
+ const body = await readJson(c);
3182
+ const goal = parseGoalField(body);
3183
+ const thinkingLevel = optionalEnum(body, "thinkingLevel", THINKING_LEVELS);
3184
+ if (goal) {
3185
+ const input2 = parseTaskInput(body);
3186
+ const text = input2.filter((m) => m.payload.type === "text").map((m) => m.payload.text).join("\n").trim();
3187
+ if (!text || input2.some((m) => m.payload.type !== "text")) {
3188
+ throw badRequest("goal mode requires text-only input (the objective).");
3189
+ }
3190
+ const { sessionId: sessionId2 } = await deps2.manager.startGoal(row.sessionId, {
3191
+ input: input2,
3192
+ budget: goal.budget,
3193
+ ...thinkingLevel !== void 0 ? { thinkingLevel } : {}
3194
+ });
3195
+ return c.json({ sessionId: sessionId2 }, 202);
3196
+ }
3197
+ const input = parseTaskInput(body);
3198
+ const queueIfBusy = body.queueIfBusy === true;
3199
+ const { sessionId, queued } = await deps2.manager.startTask(row.sessionId, input, {
3200
+ ...thinkingLevel !== void 0 ? { thinkingLevel } : {},
3201
+ queueIfBusy
3202
+ });
3203
+ return c.json({ sessionId, queued }, 202);
3204
+ });
3205
+ app2.post("/:sessionId/steer", async (c) => {
3206
+ const row = resolveSession(c);
3207
+ const body = await readJson(c);
3208
+ const text = typeof body.text === "string" ? body.text.trim() : "";
3209
+ if (!text) throw badRequest("text must be a non-empty string.");
3210
+ deps2.manager.steer(row.sessionId, text);
3211
+ return c.body(null, 202);
3212
+ });
3213
+ app2.get("/:sessionId/goal", (c) => {
3214
+ const row = resolveSession(c);
3215
+ const g = deps2.goalsRepo.latestForSession(row.sessionId);
3216
+ return c.json({
3217
+ goal: g ? {
3218
+ objective: g.objective,
3219
+ status: g.status,
3220
+ budget: g.budget,
3221
+ used: g.used,
3222
+ rounds: g.rounds,
3223
+ updatedAt: g.updatedAt
3224
+ } : null
3225
+ });
2877
3226
  });
2878
3227
  app2.post("/:sessionId/approvals/:toolCallId", async (c) => {
2879
3228
  const row = resolveSession(c);
@@ -2894,6 +3243,11 @@ function sessionsRoutes(deps2) {
2894
3243
  const aborted = deps2.manager.abortTask(row.sessionId);
2895
3244
  return c.body(null, aborted ? 202 : 204);
2896
3245
  });
3246
+ app2.post("/:sessionId/retry-now", (c) => {
3247
+ const row = resolveSession(c);
3248
+ const skipped = deps2.manager.retryNow(row.sessionId);
3249
+ return c.json({ skipped });
3250
+ });
2897
3251
  app2.post("/:sessionId/compact", async (c) => {
2898
3252
  const row = resolveSession(c);
2899
3253
  const { sessionId } = await deps2.manager.startCompact(row.sessionId);
@@ -2927,6 +3281,31 @@ function sessionsRoutes(deps2) {
2927
3281
  }
2928
3282
  });
2929
3283
  });
3284
+ app2.get("/:sessionId/files/preview-redirect", async (c) => {
3285
+ const row = resolveSession(c);
3286
+ const rel = c.req.query("path") ?? "";
3287
+ const [exists] = await deps2.workspaceFiles.statExisting(row.workspace, [rel]);
3288
+ if (!exists) throw new HttpError(404, "file_not_found", "File does not exist.");
3289
+ const target = resolvePreviewTarget(
3290
+ c.req.url,
3291
+ c.req.header("host"),
3292
+ deps2.config.previewOrigin,
3293
+ deps2.config
3294
+ );
3295
+ if (!target) {
3296
+ return c.redirect(
3297
+ `/api/sessions/${row.sessionId}/files/content?path=${encodeURIComponent(rel)}&preview=1`,
3298
+ 302
3299
+ );
3300
+ }
3301
+ const token = deps2.previewTokens.sign({
3302
+ sessionId: row.sessionId,
3303
+ host: target.host,
3304
+ expiresAt: Date.now() + PREVIEW_TOKEN_TTL_MS
3305
+ });
3306
+ const encoded = rel.split("/").map(encodeURIComponent).join("/");
3307
+ return c.redirect(`${target.origin}/preview/${token}/${encoded}`, 302);
3308
+ });
2930
3309
  app2.post("/:sessionId/files/stat", async (c) => {
2931
3310
  const row = resolveSession(c);
2932
3311
  const body = await readJson(c);
@@ -2983,6 +3362,104 @@ function sessionsRoutes(deps2) {
2983
3362
  return app2;
2984
3363
  }
2985
3364
 
3365
+ // src/http/routes/version.ts
3366
+ import { spawn } from "child_process";
3367
+ import { BUILD_DATE, VERSION } from "@prismshadow/penguin-core";
3368
+ import { Hono as Hono19 } from "hono";
3369
+ var OUTPUT_CAP = 64 * 1024;
3370
+ var UPDATE_TIMEOUT_MS = 10 * 60 * 1e3;
3371
+ var REFUSAL_MARKERS = [
3372
+ "runs from a source checkout",
3373
+ // sourceCheckout
3374
+ "Cannot tell how this penguin was installed",
3375
+ // unknownInstall
3376
+ "could not be identified",
3377
+ // npmUnknownManager
3378
+ "does not run on Windows",
3379
+ // windowsUnsupported
3380
+ "cannot run your package manager for you"
3381
+ // windowsGlobalInstall
3382
+ ];
3383
+ function classifyUpdateRun(exitCode, output) {
3384
+ if (exitCode !== 0) return { status: "failed", output, needsRestart: false };
3385
+ if (REFUSAL_MARKERS.some((marker) => output.includes(marker))) {
3386
+ return { status: "unsupported", output, needsRestart: false };
3387
+ }
3388
+ return { status: "updated", output, needsRestart: true };
3389
+ }
3390
+ function runSelfUpdate(cliEntry) {
3391
+ return new Promise((resolve) => {
3392
+ const child = spawn(process.execPath, [cliEntry, "update", "--yes"], {
3393
+ env: { ...process.env, PENGUIN_LANG: "en" },
3394
+ stdio: ["ignore", "pipe", "pipe"]
3395
+ });
3396
+ let output = "";
3397
+ const append = (chunk) => {
3398
+ output = (output + chunk.toString("utf8")).slice(-OUTPUT_CAP);
3399
+ };
3400
+ child.stdout.on("data", append);
3401
+ child.stderr.on("data", append);
3402
+ let timedOut = false;
3403
+ const timer = setTimeout(() => {
3404
+ timedOut = true;
3405
+ child.kill("SIGTERM");
3406
+ setTimeout(() => child.kill("SIGKILL"), 1e4).unref();
3407
+ }, UPDATE_TIMEOUT_MS);
3408
+ timer.unref();
3409
+ child.on("error", (err) => {
3410
+ clearTimeout(timer);
3411
+ resolve({
3412
+ status: "failed",
3413
+ output: `${output}
3414
+ [failed to run the update command: ${err.message}]`.trim(),
3415
+ needsRestart: false
3416
+ });
3417
+ });
3418
+ child.on("close", (code) => {
3419
+ clearTimeout(timer);
3420
+ if (timedOut) {
3421
+ resolve({
3422
+ status: "failed",
3423
+ output: `${output}
3424
+ [the update run was killed after ${UPDATE_TIMEOUT_MS / 6e4} minutes]`.trim(),
3425
+ needsRestart: false
3426
+ });
3427
+ return;
3428
+ }
3429
+ resolve(classifyUpdateRun(code ?? -1, output.trim()));
3430
+ });
3431
+ });
3432
+ }
3433
+ function versionRoutes(deps2) {
3434
+ const app2 = new Hono19();
3435
+ let inflightRun = null;
3436
+ app2.get("/", (c) => {
3437
+ return c.json({ version: VERSION, buildDate: BUILD_DATE });
3438
+ });
3439
+ app2.get("/update-check", async (c) => {
3440
+ return c.json(await deps2.updateCheck.check(c.req.query("force") === "1"));
3441
+ });
3442
+ app2.post("/update", async (c) => {
3443
+ if (!c.var.user.isAdmin) {
3444
+ throw new HttpError(403, "admin_required", "Only an admin can perform this operation.");
3445
+ }
3446
+ const cliEntry = process.env.PENGUIN_CLI_ENTRY;
3447
+ if (!cliEntry) {
3448
+ return c.json({
3449
+ status: "unsupported",
3450
+ reason: "not_launched_via_cli",
3451
+ output: "",
3452
+ needsRestart: false
3453
+ });
3454
+ }
3455
+ inflightRun ??= runSelfUpdate(cliEntry).finally(() => {
3456
+ inflightRun = null;
3457
+ });
3458
+ return c.json(await inflightRun);
3459
+ });
3460
+ return app2;
3461
+ }
3462
+
2986
3463
  // src/runtime/channel.ts
2987
3464
  import { randomUUID } from "crypto";
2988
3465
  var DEFAULT_MAX_COUNT = 1e4;
@@ -3007,6 +3484,16 @@ var Channel = class {
3007
3484
  get subscriberCount() {
3008
3485
  return this.listeners.size;
3009
3486
  }
3487
+ /**
3488
+ * Id of the most recently assigned event (`<epoch>-<seq>`; seq 0 when none was assigned
3489
+ * yet). Unicast (sendTo) seqs count too — the value is a position marker, not a buffer
3490
+ * lookup key. Used as the live-tail cursor on GET /messages: captured in the same
3491
+ * synchronous tick as the fragment snapshot, it tells the client which buffered events
3492
+ * the snapshot already covers.
3493
+ */
3494
+ get lastEventId() {
3495
+ return `${this.epoch}-${this.nextSeq - 1}`;
3496
+ }
3010
3497
  /** Broadcast an event: number it, buffer it (evicting the oldest), notify all subscribers. */
3011
3498
  publish(data, event) {
3012
3499
  const entry = this.makeEvent(data, event);
@@ -3179,7 +3666,11 @@ import path8 from "path";
3179
3666
  import {
3180
3667
  createAgent,
3181
3668
  findLatestTraceFile,
3669
+ goalFinishedOf,
3670
+ goalTokenDelta,
3671
+ isGoalRoundInput,
3182
3672
  isSessionMeta as isSessionMeta2,
3673
+ stripLeadingMarkerBlocks,
3183
3674
  tracesDir
3184
3675
  } from "@prismshadow/penguin-core";
3185
3676
 
@@ -3250,6 +3741,165 @@ function makeApprove(args) {
3250
3741
  };
3251
3742
  }
3252
3743
 
3744
+ // src/runtime/live-tail.ts
3745
+ import { isPartialPayload } from "@prismshadow/penguin-core";
3746
+ var FRAGMENT_CAP = 512 * 1024;
3747
+ var FRAGMENT_CAP_SLACK = 64 * 1024;
3748
+ function partialKindFor(p) {
3749
+ switch (p.type) {
3750
+ case "text":
3751
+ return p.role === "assistant" ? "partial_text" : null;
3752
+ case "thinking":
3753
+ return "partial_thinking";
3754
+ case "tool_call":
3755
+ return "partial_tool_call";
3756
+ case "tool_call_output":
3757
+ return "partial_tool_call_output";
3758
+ default:
3759
+ return null;
3760
+ }
3761
+ }
3762
+ function fragmentKey(origin, kind, toolCallId) {
3763
+ return `${(origin ?? []).join("/")}\0${kind}::${toolCallId}`;
3764
+ }
3765
+ function toolCallIdOf(p) {
3766
+ const id = p.tool_call_id;
3767
+ return typeof id === "string" ? id : "";
3768
+ }
3769
+ function appendDelta(frag, p) {
3770
+ switch (p.type) {
3771
+ case "partial_text":
3772
+ frag.buffer += p.text;
3773
+ break;
3774
+ case "partial_thinking":
3775
+ frag.buffer += p.thinking;
3776
+ break;
3777
+ case "partial_tool_call":
3778
+ frag.buffer += p.arguments;
3779
+ if (p.name) frag.name = p.name;
3780
+ frag.toolCallId = p.tool_call_id;
3781
+ break;
3782
+ case "partial_tool_call_output":
3783
+ frag.buffer += p.output;
3784
+ if (p.images && p.images.length > 0) frag.images = p.images;
3785
+ frag.toolCallId = p.tool_call_id;
3786
+ break;
3787
+ }
3788
+ if (frag.buffer.length > FRAGMENT_CAP + FRAGMENT_CAP_SLACK) {
3789
+ frag.buffer = frag.buffer.slice(frag.buffer.length - FRAGMENT_CAP);
3790
+ }
3791
+ }
3792
+ function startPayload(frag) {
3793
+ switch (frag.kind) {
3794
+ case "partial_text":
3795
+ return { type: "partial_text", role: "assistant", event_type: "start", text: frag.buffer };
3796
+ case "partial_thinking":
3797
+ return {
3798
+ type: "partial_thinking",
3799
+ role: "assistant",
3800
+ event_type: "start",
3801
+ thinking: frag.buffer
3802
+ };
3803
+ case "partial_tool_call":
3804
+ return {
3805
+ type: "partial_tool_call",
3806
+ role: "assistant",
3807
+ event_type: "start",
3808
+ name: frag.name ?? "",
3809
+ arguments: frag.buffer,
3810
+ tool_call_id: frag.toolCallId ?? ""
3811
+ };
3812
+ case "partial_tool_call_output":
3813
+ return {
3814
+ type: "partial_tool_call_output",
3815
+ role: "user",
3816
+ event_type: "start",
3817
+ output: frag.buffer,
3818
+ ...frag.images !== void 0 && frag.images.length > 0 ? { images: frag.images } : {},
3819
+ tool_call_id: frag.toolCallId ?? ""
3820
+ };
3821
+ }
3822
+ }
3823
+ var LiveTailTracker = class {
3824
+ /** sessionId → open fragments keyed by fragmentKey, in the order they were opened. */
3825
+ sessions = /* @__PURE__ */ new Map();
3826
+ /** Feed one published message (call in the same synchronous tick as the channel publish). */
3827
+ observe(sessionId, msg) {
3828
+ if (msg.type !== "model_msg") return;
3829
+ const p = msg.payload;
3830
+ if (!isPartialPayload(p)) {
3831
+ const kind = partialKindFor(p);
3832
+ if (kind === null) return;
3833
+ const open3 = this.sessions.get(sessionId);
3834
+ if (!open3) return;
3835
+ open3.delete(fragmentKey(msg.origin, kind, toolCallIdOf(p)));
3836
+ if (open3.size === 0) this.sessions.delete(sessionId);
3837
+ return;
3838
+ }
3839
+ const key = fragmentKey(msg.origin, p.type, toolCallIdOf(p));
3840
+ let open2 = this.sessions.get(sessionId);
3841
+ if (p.event_type === "start") {
3842
+ if (!open2) {
3843
+ open2 = /* @__PURE__ */ new Map();
3844
+ this.sessions.set(sessionId, open2);
3845
+ }
3846
+ const frag2 = {
3847
+ kind: p.type,
3848
+ timestamp: msg.timestamp,
3849
+ buffer: "",
3850
+ ...msg.origin && msg.origin.length > 0 ? { origin: [...msg.origin] } : {}
3851
+ };
3852
+ open2.delete(key);
3853
+ open2.set(key, frag2);
3854
+ appendDelta(frag2, p);
3855
+ return;
3856
+ }
3857
+ let frag = open2?.get(key);
3858
+ if (!frag) {
3859
+ if (p.event_type === "stop") return;
3860
+ if (!open2) {
3861
+ open2 = /* @__PURE__ */ new Map();
3862
+ this.sessions.set(sessionId, open2);
3863
+ }
3864
+ frag = {
3865
+ kind: p.type,
3866
+ timestamp: msg.timestamp,
3867
+ buffer: "",
3868
+ ...msg.origin && msg.origin.length > 0 ? { origin: [...msg.origin] } : {}
3869
+ };
3870
+ open2.set(key, frag);
3871
+ }
3872
+ appendDelta(frag, p);
3873
+ if (p.event_type === "stop") {
3874
+ open2.delete(key);
3875
+ if (open2.size === 0) this.sessions.delete(sessionId);
3876
+ }
3877
+ }
3878
+ /**
3879
+ * Synthetic `partial_* start` messages for every open fragment (in open order), each
3880
+ * carrying the full accumulated content, the original origin chain, and the original
3881
+ * start timestamp. Empty when the session has no open fragments.
3882
+ */
3883
+ fragments(sessionId) {
3884
+ const open2 = this.sessions.get(sessionId);
3885
+ if (!open2) return [];
3886
+ const out = [];
3887
+ for (const frag of open2.values()) {
3888
+ out.push({
3889
+ timestamp: frag.timestamp,
3890
+ type: "model_msg",
3891
+ payload: startPayload(frag),
3892
+ ...frag.origin !== void 0 ? { origin: [...frag.origin] } : {}
3893
+ });
3894
+ }
3895
+ return out;
3896
+ }
3897
+ /** Drop all fragment state for a session (the run ended; nothing will continue these fragments). */
3898
+ clear(sessionId) {
3899
+ this.sessions.delete(sessionId);
3900
+ }
3901
+ };
3902
+
3253
3903
  // src/runtime/session-sources.ts
3254
3904
  function asSessionSource(v) {
3255
3905
  return v === "schedule" || v === "subagent" ? v : void 0;
@@ -3277,6 +3927,9 @@ var TOOL_NAMES_MAX = 1e3;
3277
3927
  var ORIGIN_CTX_MAX = 200;
3278
3928
  var LLM_FAILURES = {
3279
3929
  failed: { code: "llm_failed", kind: "unexpected", text: "LLM request failed (not retryable)." },
3930
+ // Credentials rejection shares the failed bucket (no new taxonomy): same code/kind, the
3931
+ // real reason arrives via the abort reason or the event's own message as usual.
3932
+ auth: { code: "llm_failed", kind: "unexpected", text: "LLM request failed (not retryable)." },
3280
3933
  timeout: {
3281
3934
  code: "llm_timeout",
3282
3935
  kind: "expected",
@@ -3289,7 +3942,7 @@ var LLM_FAILURES = {
3289
3942
  }
3290
3943
  };
3291
3944
  function isLlmFailure(s) {
3292
- return s === "failed" || s === "timeout" || s === "malformed";
3945
+ return s === "failed" || s === "timeout" || s === "malformed" || s === "auth";
3293
3946
  }
3294
3947
  function isToolFailure(s) {
3295
3948
  return s === "failed" || s === "timeout";
@@ -3313,7 +3966,7 @@ var StreamErrorWatcher = class {
3313
3966
  }
3314
3967
  errors;
3315
3968
  ctx;
3316
- /** LLM failures awaiting a real reason: origin → failure state (see file header; each session has at most one in-flight Request). */
3969
+ /** 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). */
3317
3970
  pending = /* @__PURE__ */ new Map();
3318
3971
  /** Names of in-flight tool calls: `origin \0 tool_call_id` → tool name (dequeued once output arrives). */
3319
3972
  toolNames = /* @__PURE__ */ new Map();
@@ -3377,7 +4030,14 @@ var StreamErrorWatcher = class {
3377
4030
  const key = originKey(msg);
3378
4031
  if (p.type === "request_end") {
3379
4032
  this.flush(key);
3380
- if (isLlmFailure(p.status)) this.pending.set(key, p.status);
4033
+ if (isLlmFailure(p.status)) {
4034
+ this.pending.set(key, {
4035
+ status: p.status,
4036
+ // The event's own failure detail (LLMOutcome.message): the message of record when
4037
+ // no abort follows (the retry path).
4038
+ ...typeof p.message === "string" && p.message.trim() ? { message: p.message } : {}
4039
+ });
4040
+ }
3381
4041
  return;
3382
4042
  }
3383
4043
  if (p.type === "request_begin") {
@@ -3395,12 +4055,12 @@ var StreamErrorWatcher = class {
3395
4055
  * up from it (see file header).
3396
4056
  */
3397
4057
  flush(key, reason) {
3398
- const status = this.pending.get(key);
3399
- if (status === void 0) return;
4058
+ const entry = this.pending.get(key);
4059
+ if (entry === void 0) return;
3400
4060
  this.pending.delete(key);
3401
- const spec = LLM_FAILURES[status];
4061
+ const spec = LLM_FAILURES[entry.status];
3402
4062
  const trimmed = reason?.trim();
3403
- const message = trimmed && !isUserAbortReason(trimmed) ? trimmed : spec.text;
4063
+ const message = trimmed && !isUserAbortReason(trimmed) ? trimmed : entry.message ?? spec.text;
3404
4064
  this.errors.record({
3405
4065
  source: "llm",
3406
4066
  err: message,
@@ -3564,6 +4224,8 @@ var SessionManager = class {
3564
4224
  deletingSessions = /* @__PURE__ */ new Set();
3565
4225
  /** Per-Agent config generation (key = agentKey), bumped by invalidateAgentRuntimes on vault updates. */
3566
4226
  agentGenerations = /* @__PURE__ */ new Map();
4227
+ /** Open streaming fragments of running sessions (fed by drive, served to GET /messages; see live-tail.ts). */
4228
+ liveTail = new LiveTailTracker();
3567
4229
  sweepTimer;
3568
4230
  // —— Query surface (used by Session listing / Agent active-count / SSE subscription replay) ——
3569
4231
  statusOf(sessionId) {
@@ -3575,6 +4237,19 @@ var SessionManager = class {
3575
4237
  pendingApprovals(sessionId) {
3576
4238
  return this.entries.get(sessionId)?.approvals.list() ?? [];
3577
4239
  }
4240
+ /** Number of queued follow-up tasks (`queueIfBusy`) awaiting auto-start. */
4241
+ pendingFollowUpCount(sessionId) {
4242
+ return this.entries.get(sessionId)?.followUps.length ?? 0;
4243
+ }
4244
+ /**
4245
+ * Live tail of a running session: one synthetic `partial_* start` OmniMessage per open
4246
+ * streaming fragment, carrying the full accumulated content so far (see live-tail.ts).
4247
+ * Empty when idle or when nothing is streaming. GET /messages attaches this (with a
4248
+ * channel cursor) so a client joining mid-stream can render the in-progress message.
4249
+ */
4250
+ liveFragments(sessionId) {
4251
+ return this.liveTail.fragments(sessionId);
4252
+ }
3578
4253
  /** Number of Sessions for this Agent that are currently running / compacting. */
3579
4254
  activeCountForAgent(projectId, agentId) {
3580
4255
  let n = 0;
@@ -3597,6 +4272,7 @@ var SessionManager = class {
3597
4272
  abort: null,
3598
4273
  running: null,
3599
4274
  generation: this.generationOf(row.projectId, row.agentId),
4275
+ followUps: [],
3600
4276
  lastActivityMs: Date.now()
3601
4277
  });
3602
4278
  }
@@ -3613,28 +4289,77 @@ var SessionManager = class {
3613
4289
  const key = agentKey(projectId, agentId);
3614
4290
  this.agentGenerations.set(key, this.generationOf(projectId, agentId) + 1);
3615
4291
  }
4292
+ /**
4293
+ * After a Project's models/credentials change: invalidate every cached runtime in this
4294
+ * Project, so the next Task re-resumes with the new api_key / base_url. Same
4295
+ * effective-value semantics as invalidateAgentRuntimes — no hot swap into a Task already
4296
+ * in flight. Iterating the active table is complete here, not a shortcut: the generation
4297
+ * map only matters for runtimes that are already cached, and an Agent with no cached
4298
+ * entry builds fresh through the loader anyway.
4299
+ */
4300
+ invalidateProjectRuntimes(projectId) {
4301
+ const agentIds = /* @__PURE__ */ new Set();
4302
+ for (const e of this.entries.values()) {
4303
+ if (e.projectId === projectId) agentIds.add(e.agentId);
4304
+ }
4305
+ for (const agentId of agentIds) this.invalidateAgentRuntimes(projectId, agentId);
4306
+ }
3616
4307
  // —— Task / compaction drive ——
3617
4308
  /**
3618
4309
  * Start a Task: get-or-load → 409
3619
4310
  * mutual-exclusion check → publish the input messages first → drive run in the
3620
4311
  * background. Returns the current actual session_id (the new id after self-heal).
4312
+ * `opts.thinkingLevel` (optional, validated by the route) rides into this run's
4313
+ * `session.run` options — a per-turn parameter, applied to this Task only.
4314
+ * With `queueIfBusy`, a busy session (running/compacting) enqueues the input as a
4315
+ * follow-up instead of 409: it auto-starts as an ordinary next task once the session
4316
+ * returns to idle (`queued: true` in the result; see startQueuedFollowUp), keeping its
4317
+ * thinkingLevel for that auto-start.
3621
4318
  */
3622
- async startTask(sessionId, input) {
4319
+ async startTask(sessionId, input, opts) {
3623
4320
  return this.withLock(sessionId, async () => {
3624
4321
  this.assertOpen();
3625
4322
  this.assertAgentNotDeleting(sessionId);
3626
4323
  this.assertSessionNotDeleting(sessionId);
3627
4324
  const entry = await this.ensureEntry(sessionId);
4325
+ if (entry.status !== "idle" && opts?.queueIfBusy) {
4326
+ entry.followUps.push({
4327
+ input,
4328
+ ...opts.thinkingLevel !== void 0 ? { thinkingLevel: opts.thinkingLevel } : {}
4329
+ });
4330
+ entry.lastActivityMs = Date.now();
4331
+ this.publishState(entry, entry.status);
4332
+ return { sessionId: entry.sessionId, queued: true };
4333
+ }
3628
4334
  this.assertIdle(entry);
3629
- const channel = this.deps.channels.get(entry.sessionId);
4335
+ this.launchTask(entry, input, opts?.thinkingLevel);
4336
+ return { sessionId: entry.sessionId, queued: false };
4337
+ });
4338
+ }
4339
+ /**
4340
+ * Start a goal run: like startTask, but the run is core goal mode — one
4341
+ * `session.run(input, { goal })` call loops rounds until a terminal state, and the
4342
+ * Session stays `running` for the whole goal (every round), so the existing abort
4343
+ * endpoint interrupts the entire loop and schedules queue behind it as usual. Round
4344
+ * inputs are yielded by core and published like any streamed message; progress
4345
+ * additionally goes out as goal_* server events and into goal_state (when a repo is
4346
+ * wired).
4347
+ */
4348
+ async startGoal(sessionId, args) {
4349
+ return this.withLock(sessionId, async () => {
4350
+ this.assertOpen();
4351
+ this.assertAgentNotDeleting(sessionId);
4352
+ this.assertSessionNotDeleting(sessionId);
4353
+ const entry = await this.ensureEntry(sessionId);
4354
+ this.assertIdle(entry);
4355
+ const text = args.input.filter(isPlainText("user")).map((m) => m.payload.text).join("\n");
4356
+ const objective = stripLeadingMarkerBlocks(text).trim() || text.trim();
3630
4357
  const ac = new AbortController();
3631
4358
  entry.status = "running";
3632
4359
  entry.abort = ac;
3633
4360
  entry.lastActivityMs = Date.now();
3634
- for (const msg of input) channel.publish(msg);
3635
4361
  this.publishState(entry, "running");
3636
4362
  const approve = makeApprove({
3637
- // Re-reads approval_mode from the DB on every decision (a PATCH takes effect immediately).
3638
4363
  getMode: () => this.deps.sessions.findById(entry.sessionId)?.approvalMode ?? "always-ask",
3639
4364
  toolPermission: (name) => entry.session.toolPermission(name),
3640
4365
  registry: entry.approvals,
@@ -3644,12 +4369,153 @@ var SessionManager = class {
3644
4369
  ...pending.origin !== void 0 ? { origin: pending.origin } : {}
3645
4370
  })
3646
4371
  });
3647
- const gen = entry.session.run(input, { approve, signal: ac.signal });
3648
- const userExcerpt = input.filter(isPlainText("user")).map((m) => m.payload.text).join("\n");
3649
- entry.running = this.drive(entry, gen, { userExcerpt });
4372
+ const goalId = this.deps.goals?.create({
4373
+ sessionId: entry.sessionId,
4374
+ projectId: entry.projectId,
4375
+ agentId: entry.agentId,
4376
+ objective,
4377
+ budget: args.budget
4378
+ });
4379
+ this.publishEvent(entry, {
4380
+ type: "goal_started",
4381
+ sessionId: entry.sessionId,
4382
+ objective,
4383
+ budget: args.budget
4384
+ });
4385
+ const gen = this.goalStream(entry, {
4386
+ input: args.input,
4387
+ budget: args.budget,
4388
+ ...args.thinkingLevel !== void 0 ? { thinkingLevel: args.thinkingLevel } : {},
4389
+ approve,
4390
+ signal: ac.signal,
4391
+ ...goalId !== void 0 ? { goalId } : {}
4392
+ });
4393
+ entry.running = this.drive(entry, gen, { userExcerpt: objective });
3650
4394
  return { sessionId: entry.sessionId };
3651
4395
  });
3652
4396
  }
4397
+ /**
4398
+ * Taps core's goal-mode stream for `drive`: round boundaries (the injected `[goal]`
4399
+ * inputs) become goal_round events + goal_state refreshes, and the terminal
4400
+ * `goal_finished` event message becomes the goal_finished server event + the run-state
4401
+ * row's final status. Token numbers mirror core's own accounting (same
4402
+ * `goalTokenDelta`), so the UI shows exactly what the budget check uses.
4403
+ */
4404
+ async *goalStream(entry, args) {
4405
+ const gen = entry.session.run(args.input, {
4406
+ approve: args.approve,
4407
+ signal: args.signal,
4408
+ ...args.thinkingLevel !== void 0 ? { thinkingLevel: args.thinkingLevel } : {},
4409
+ goal: { budget: args.budget }
4410
+ });
4411
+ let round = 0;
4412
+ let used = 0;
4413
+ let finished = false;
4414
+ try {
4415
+ for await (const msg of gen) {
4416
+ used += goalTokenDelta(msg);
4417
+ if (isGoalRoundInput(msg)) {
4418
+ round++;
4419
+ if (args.goalId !== void 0) this.deps.goals?.progress(args.goalId, round, used);
4420
+ this.publishEvent(entry, {
4421
+ type: "goal_round",
4422
+ sessionId: entry.sessionId,
4423
+ round,
4424
+ used,
4425
+ budget: args.budget
4426
+ });
4427
+ }
4428
+ const outcome = goalFinishedOf(msg);
4429
+ if (outcome) {
4430
+ finished = true;
4431
+ if (args.goalId !== void 0) {
4432
+ this.deps.goals?.finish(
4433
+ args.goalId,
4434
+ outcome.outcome,
4435
+ outcome.rounds,
4436
+ outcome.tokensUsed
4437
+ );
4438
+ }
4439
+ this.publishEvent(entry, {
4440
+ type: "goal_finished",
4441
+ sessionId: entry.sessionId,
4442
+ outcome: outcome.outcome,
4443
+ rounds: outcome.rounds,
4444
+ used: outcome.tokensUsed
4445
+ });
4446
+ }
4447
+ yield msg;
4448
+ }
4449
+ if (!finished) {
4450
+ this.finishAborted(entry, args.goalId, round, used);
4451
+ }
4452
+ } catch (err) {
4453
+ if (!finished) this.finishAborted(entry, args.goalId, round, used);
4454
+ throw err;
4455
+ }
4456
+ }
4457
+ /** Closes a goal's run state as aborted (stream cut off / infrastructure failure). */
4458
+ finishAborted(entry, goalId, round, used) {
4459
+ if (goalId !== void 0) this.deps.goals?.finish(goalId, "aborted", round, used);
4460
+ this.publishEvent(entry, {
4461
+ type: "goal_finished",
4462
+ sessionId: entry.sessionId,
4463
+ outcome: "aborted",
4464
+ rounds: round,
4465
+ used
4466
+ });
4467
+ }
4468
+ /** 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. */
4469
+ launchTask(entry, input, thinkingLevel) {
4470
+ const channel = this.deps.channels.get(entry.sessionId);
4471
+ const ac = new AbortController();
4472
+ entry.status = "running";
4473
+ entry.abort = ac;
4474
+ entry.lastActivityMs = Date.now();
4475
+ for (const msg of input) channel.publish(msg);
4476
+ this.publishState(entry, "running");
4477
+ const approve = makeApprove({
4478
+ // Re-reads approval_mode from the DB on every decision (a PATCH takes effect immediately).
4479
+ getMode: () => this.deps.sessions.findById(entry.sessionId)?.approvalMode ?? "always-ask",
4480
+ toolPermission: (name) => entry.session.toolPermission(name),
4481
+ registry: entry.approvals,
4482
+ publishRequest: (pending) => this.publishEvent(entry, {
4483
+ type: "approval_request",
4484
+ toolCall: pending.toolCall,
4485
+ ...pending.origin !== void 0 ? { origin: pending.origin } : {}
4486
+ })
4487
+ });
4488
+ const gen = entry.session.run(input, {
4489
+ approve,
4490
+ signal: ac.signal,
4491
+ ...thinkingLevel !== void 0 ? { thinkingLevel } : {}
4492
+ });
4493
+ const userExcerpt = input.filter(isPlainText("user")).map((m) => m.payload.text).join("\n");
4494
+ entry.running = this.drive(entry, gen, { userExcerpt });
4495
+ }
4496
+ /**
4497
+ * Auto-start of queued follow-ups: called from drive's finally whenever a Task or
4498
+ * compaction finishes (abort included — follow-ups are future tasks the user queued, so
4499
+ * an abort of the current run does not discard them). Re-validates everything under the
4500
+ * session lock (another task may have snuck in, the server may be shutting down, the
4501
+ * session may be getting deleted) and launches exactly one queued input — the next
4502
+ * finish picks up the one after, keeping order and one-at-a-time semantics.
4503
+ */
4504
+ async startQueuedFollowUp(sessionId) {
4505
+ try {
4506
+ await this.withLock(sessionId, async () => {
4507
+ if (this.closed || this.deletingSessions.has(sessionId)) return;
4508
+ const row = this.deps.sessions.findById(sessionId);
4509
+ if (row && this.deletingAgents.has(agentKey(row.projectId, row.agentId))) return;
4510
+ const entry = this.entries.get(sessionId);
4511
+ if (!entry || entry.status !== "idle" || entry.followUps.length === 0) return;
4512
+ const next = entry.followUps.shift();
4513
+ this.launchTask(entry, next.input, next.thinkingLevel);
4514
+ });
4515
+ } catch (err) {
4516
+ this.log(`[followup] auto-start failed: ${err instanceof Error ? err.message : String(err)}`);
4517
+ }
4518
+ }
3653
4519
  /** Manually compact the context: 409 if already running; compaction output also flows into the SSE channel. */
3654
4520
  async startCompact(sessionId) {
3655
4521
  return this.withLock(sessionId, async () => {
@@ -3676,6 +4542,39 @@ var SessionManager = class {
3676
4542
  if (!entry) return false;
3677
4543
  return entry.approvals.decide(toolCallId, decision);
3678
4544
  }
4545
+ /**
4546
+ * Mid-run steering: forward the text to the running Session (core delivers it between
4547
+ * turns as a standalone `[user_steering]` user message — no SSE event of its own; the
4548
+ * message arrives through the stream the drive loop already publishes). 409 when the
4549
+ * Session isn't running a Task (idle / compacting / not loaded) or the run finished in the
4550
+ * race window — the caller falls back to submitting a normal task.
4551
+ */
4552
+ steer(sessionId, text) {
4553
+ const entry = this.entries.get(sessionId);
4554
+ if (!entry || entry.status !== "running" || !entry.session.steer(text)) {
4555
+ throw new HttpError(
4556
+ 409,
4557
+ "not_running",
4558
+ "This Session has no Task in progress; send the message as a new task instead."
4559
+ );
4560
+ }
4561
+ entry.lastActivityMs = Date.now();
4562
+ }
4563
+ /**
4564
+ * "Retry now" on the reconnect countdown: skip the in-progress backoff wait and fire
4565
+ * the next retry immediately (the attempt counter is unchanged — the skipped wait does
4566
+ * not consume an extra attempt). Returns false as a benign no-op when the session has
4567
+ * no active runtime or no reconnect wait is in progress — timing races (the wait
4568
+ * elapsed right before the click) must not surface as errors. Mirrors the steer seam:
4569
+ * manager → RuntimeSession → core Session → engine.
4570
+ */
4571
+ retryNow(sessionId) {
4572
+ const entry = this.entries.get(sessionId);
4573
+ if (!entry || entry.status === "idle") return false;
4574
+ const skipped = entry.session.skipReconnectWait();
4575
+ if (skipped) entry.lastActivityMs = Date.now();
4576
+ return skipped;
4577
+ }
3679
4578
  /**
3680
4579
  * Interrupt the current Task/compaction: pending approvals converge to deny first,
3681
4580
  * then the AbortSignal fires. Returns false if nothing is in progress (the route
@@ -3781,6 +4680,7 @@ var SessionManager = class {
3781
4680
  sweepIdle(now = Date.now(), idleMs = ENTRY_IDLE_MS) {
3782
4681
  for (const [key, entry] of this.entries) {
3783
4682
  if (entry.status !== "idle" || entry.approvals.size !== 0 || entry.running !== null) continue;
4683
+ if (entry.followUps.length > 0) continue;
3784
4684
  if (now - entry.lastActivityMs <= idleMs) continue;
3785
4685
  this.entries.delete(key);
3786
4686
  }
@@ -3872,6 +4772,7 @@ var SessionManager = class {
3872
4772
  abort: null,
3873
4773
  running: null,
3874
4774
  generation,
4775
+ followUps: [],
3875
4776
  lastActivityMs: Date.now()
3876
4777
  };
3877
4778
  this.entries.set(currentId, entry);
@@ -3949,6 +4850,7 @@ var SessionManager = class {
3949
4850
  }
3950
4851
  }
3951
4852
  }
4853
+ this.liveTail.observe(entry.sessionId, msg);
3952
4854
  this.deps.channels.get(entry.sessionId).publish(msg);
3953
4855
  watcher?.observe(msg);
3954
4856
  try {
@@ -3965,6 +4867,7 @@ var SessionManager = class {
3965
4867
  this.deps.errors?.record({ source: "session", err, ctx, code: "session_run_failed" });
3966
4868
  } finally {
3967
4869
  watcher?.close();
4870
+ this.liveTail.clear(entry.sessionId);
3968
4871
  entry.approvals.denyAll();
3969
4872
  entry.status = "idle";
3970
4873
  entry.abort = null;
@@ -3976,6 +4879,7 @@ var SessionManager = class {
3976
4879
  fallbackText: titleSource.userExcerpt
3977
4880
  });
3978
4881
  }
4882
+ if (entry.followUps.length > 0) void this.startQueuedFollowUp(entry.sessionId);
3979
4883
  for (const child of children.values()) {
3980
4884
  if (!child.prompt.trim()) continue;
3981
4885
  this.deps.titles?.maybeGenerate(
@@ -4046,7 +4950,7 @@ var SessionManager = class {
4046
4950
  return child;
4047
4951
  }
4048
4952
  publishState(entry, state) {
4049
- this.publishEvent(entry, { type: "task_state", state });
4953
+ this.publishEvent(entry, { type: "task_state", state, queued: entry.followUps.length });
4050
4954
  }
4051
4955
  publishEvent(entry, event) {
4052
4956
  this.deps.channels.get(entry.sessionId).publish(event, "server_event");
@@ -4069,19 +4973,9 @@ var SessionManager = class {
4069
4973
  // src/runtime/scheduler.ts
4070
4974
  import { createHash as createHash3 } from "crypto";
4071
4975
  import { readdir } from "fs/promises";
4072
- import { agentsDir, userText as userText2 } from "@prismshadow/penguin-core";
4976
+ import { agentsDir, buildScheduledMessage, userText as userText2 } from "@prismshadow/penguin-core";
4073
4977
  var TICK_INTERVAL_MS = 3e4;
4074
- function scheduledMessage(name, firedAt, prompt) {
4075
- return [
4076
- "<scheduled_task>",
4077
- "This message was sent automatically by a scheduled task; its origin is listed below and the task prompt follows.",
4078
- `schedule: ${name}`,
4079
- `fired_at: ${firedAt}`,
4080
- "</scheduled_task>",
4081
- "",
4082
- prompt
4083
- ].join("\n");
4084
- }
4978
+ var scheduledMessage = buildScheduledMessage;
4085
4979
  var Scheduler = class {
4086
4980
  constructor(deps2) {
4087
4981
  this.deps = deps2;
@@ -4500,7 +5394,7 @@ var UsageRecorder = class {
4500
5394
  };
4501
5395
 
4502
5396
  // src/services/ids.ts
4503
- import { randomBytes as randomBytes3 } from "crypto";
5397
+ import { randomBytes as randomBytes4 } from "crypto";
4504
5398
  var SEMANTIC_ID_PATTERN = /^[a-z][a-z0-9_]{1,63}$/;
4505
5399
  var USERNAME_PATTERN = /^[a-z][a-z0-9_]{1,31}$/;
4506
5400
  var PROJECT_SUFFIX_PATTERN = /^[a-z0-9_]+$/;
@@ -4585,6 +5479,7 @@ import {
4585
5479
  VAULT_VALUE_MAX_LENGTH,
4586
5480
  isValidVaultKey,
4587
5481
  loadAgentVault,
5482
+ resetSystemConfigToDefaults,
4588
5483
  saveAgentVault,
4589
5484
  systemConfigPath
4590
5485
  } from "@prismshadow/penguin-core";
@@ -4643,6 +5538,19 @@ var ProjectConfigService = class {
4643
5538
  filePath(projectId) {
4644
5539
  return projectConfigPath(this.root, projectId);
4645
5540
  }
5541
+ /**
5542
+ * When the Project config (models/credentials) last changed: the config file's mtime as
5543
+ * ISO — an honest persistent source that survives server restarts (every models update
5544
+ * rewrites the file). undefined when the file doesn't exist yet.
5545
+ */
5546
+ async configUpdatedAt(projectId) {
5547
+ try {
5548
+ const st = await fs9.stat(this.filePath(projectId));
5549
+ return st.mtime.toISOString();
5550
+ } catch {
5551
+ return void 0;
5552
+ }
5553
+ }
4646
5554
  /** Reads the raw TOML object; returns an empty object if the file doesn't exist (does not write to disk). */
4647
5555
  async readRaw(projectId) {
4648
5556
  let raw;
@@ -4860,9 +5768,11 @@ var ProjectConfigService = class {
4860
5768
  provider: ref.provider,
4861
5769
  modelId: ref.model_id
4862
5770
  });
5771
+ const updatedAt = await this.configUpdatedAt(projectId);
4863
5772
  return {
4864
5773
  ...defaultRef !== void 0 ? { defaultModel: toDto(defaultRef) } : {},
4865
5774
  ...visionRef !== void 0 ? { visionModel: toDto(visionRef) } : {},
5775
+ ...updatedAt !== void 0 ? { updatedAt } : {},
4866
5776
  models
4867
5777
  };
4868
5778
  }
@@ -5013,7 +5923,7 @@ function probeTps(outputTokens, windowMs) {
5013
5923
  }
5014
5924
 
5015
5925
  // src/services/agent-config-service.ts
5016
- var THINKING_LEVELS = ["none", "low", "medium", "high", "xhigh"];
5926
+ var THINKING_LEVELS2 = ["none", "low", "medium", "high", "xhigh"];
5017
5927
  var COMPACTION_MODES = ["summarize", "discard"];
5018
5928
  function asRecord(v) {
5019
5929
  return v !== null && typeof v === "object" && !Array.isArray(v) ? v : {};
@@ -5115,6 +6025,18 @@ var AgentConfigService = class {
5115
6025
  await fs10.writeFile(agentsMdPath(this.root, projectId, agentId), req.agentsMd, "utf8");
5116
6026
  }
5117
6027
  }
6028
+ /**
6029
+ * Overwrites system_config.yaml with the current code defaults (core's
6030
+ * resetSystemConfigToDefaults) — same semantics as updating an installed skill:
6031
+ * only the identity fields (name / description / version) survive; the system
6032
+ * prompt, max_turns, model/compaction settings and the tool list (incl. MCP
6033
+ * servers) become the current defaults. AGENTS.md, skills and the vault are
6034
+ * untouched.
6035
+ */
6036
+ async resetConfig(projectId, agentId) {
6037
+ await this.requireExists(projectId, agentId);
6038
+ await resetSystemConfigToDefaults(this.root, projectId, agentId);
6039
+ }
5118
6040
  async applyConfigUpdate(projectId, agentId, config2) {
5119
6041
  const cfg = config2;
5120
6042
  const yamlPath = systemConfigPath(this.root, projectId, agentId);
@@ -5143,7 +6065,7 @@ var AgentConfigService = class {
5143
6065
  );
5144
6066
  setIfProvided(
5145
6067
  ["model", "thinking_level"],
5146
- optionalEnum(model, "thinkingLevel", THINKING_LEVELS)
6068
+ optionalEnum(model, "thinkingLevel", THINKING_LEVELS2)
5147
6069
  );
5148
6070
  setIfProvided(
5149
6071
  ["model", "timeoutMs"],
@@ -5237,6 +6159,7 @@ function validateToolsBuiltin(value) {
5237
6159
  if (t.forModel !== void 0 && t.forModel !== "vision" && t.forModel !== "text-only") {
5238
6160
  throw badRequest(`toolsBuiltin[${i}].forModel must be one of vision / text-only.`);
5239
6161
  }
6162
+ optionalBoolean(t, "call_description", `toolsBuiltin[${i}].call_description`);
5240
6163
  optionalNumber(t, "timeoutMs", {
5241
6164
  integer: true,
5242
6165
  positiveOrMinusOne: true,
@@ -5615,7 +6538,7 @@ var BenchmarkService = class {
5615
6538
  };
5616
6539
 
5617
6540
  // src/services/snapshot-service.ts
5618
- import { randomBytes as randomBytes4 } from "crypto";
6541
+ import { randomBytes as randomBytes5 } from "crypto";
5619
6542
  import fs13 from "fs/promises";
5620
6543
  import path12 from "path";
5621
6544
  import * as tar from "tar";
@@ -5661,7 +6584,7 @@ var SnapshotService = class {
5661
6584
  } catch {
5662
6585
  }
5663
6586
  await fs13.mkdir(dir, { recursive: true });
5664
- const tmp = `${file}.tmp-${randomBytes4(4).toString("hex")}`;
6587
+ const tmp = `${file}.tmp-${randomBytes5(4).toString("hex")}`;
5665
6588
  await tar.create(
5666
6589
  {
5667
6590
  gzip: true,
@@ -5689,7 +6612,7 @@ var SnapshotService = class {
5689
6612
  async importArchive(projectId, agentId, archive, confirm) {
5690
6613
  const current = await this.currentVersion(projectId, agentId);
5691
6614
  const base = agentDir2(this.root, projectId, agentId);
5692
- const staging = path12.join(base, `.import-${randomBytes4(6).toString("hex")}`);
6615
+ const staging = path12.join(base, `.import-${randomBytes5(6).toString("hex")}`);
5693
6616
  await fs13.mkdir(staging, { recursive: true });
5694
6617
  try {
5695
6618
  const archiveFile = path12.join(staging, "archive.tar.gz");
@@ -5971,6 +6894,7 @@ var ProjectService = class {
5971
6894
  this.deps.usage.deleteByProject(projectId);
5972
6895
  this.deps.errors.deleteByProject(projectId);
5973
6896
  this.deps.schedules.deleteByProject(projectId);
6897
+ this.deps.goals.deleteByProject(projectId);
5974
6898
  await fs14.rm(projectDir(this.deps.root, projectId), { recursive: true, force: true });
5975
6899
  }
5976
6900
  // —— Member authorization ——
@@ -6043,15 +6967,39 @@ var ProjectService = class {
6043
6967
 
6044
6968
  // src/services/session-service.ts
6045
6969
  import path13 from "path";
6046
- import { readdir as readdir2 } from "fs/promises";
6970
+ import { open, readdir as readdir2 } from "fs/promises";
6047
6971
  import {
6048
6972
  createAgent as createAgent2,
6973
+ findLatestTraceFile as findLatestTraceFile2,
6049
6974
  isSessionMeta as isSessionMeta4,
6975
+ parseTraceLines,
6050
6976
  readTraceTolerant,
6051
6977
  tracesDir as tracesDir2
6052
6978
  } from "@prismshadow/penguin-core";
6053
6979
  var TRACE_FILE_RE = /^(.+)_(\d{3})\.jsonl$/;
6054
6980
  var SESSION_ID_TS_RE = /^session-(\d{4})-(\d{2})-(\d{2})-(\d{2})-(\d{2})-(\d{2})-[0-9a-f]{8}$/;
6981
+ var TRACE_HEAD_BYTES = 256 * 1024;
6982
+ async function readTraceHead(filePath) {
6983
+ const fh = await open(filePath, "r");
6984
+ let text;
6985
+ let truncated;
6986
+ try {
6987
+ const { buffer, bytesRead } = await fh.read(
6988
+ Buffer.allocUnsafe(TRACE_HEAD_BYTES),
6989
+ 0,
6990
+ TRACE_HEAD_BYTES,
6991
+ 0
6992
+ );
6993
+ text = buffer.subarray(0, bytesRead).toString("utf8");
6994
+ truncated = bytesRead === TRACE_HEAD_BYTES;
6995
+ } finally {
6996
+ await fh.close();
6997
+ }
6998
+ if (!truncated) return parseTraceLines(text);
6999
+ const nl = text.lastIndexOf("\n");
7000
+ if (nl === -1) return readTraceTolerant(filePath);
7001
+ return parseTraceLines(text.slice(0, nl + 1));
7002
+ }
6055
7003
  function sessionIdCreatedAt(sessionId) {
6056
7004
  const m = SESSION_ID_TS_RE.exec(sessionId);
6057
7005
  if (!m) return null;
@@ -6067,10 +7015,11 @@ var SessionService = class {
6067
7015
  /**
6068
7016
  * DB row -> SessionInfo (run status and pending approval count come from session-manager).
6069
7017
  * Async because `source` is derived from session_meta: a registry miss (Session predating
6070
- * this process) falls back to reading the Trace head once (see sourceOf).
7018
+ * this process) falls back to reading the Trace head once (see sourceOf). `traces` is the
7019
+ * list flow's one-walk discovery result; without it a miss locates the shard itself.
6071
7020
  */
6072
- async toInfo(row, hasTrace) {
6073
- const source = await this.sourceOf(row, hasTrace);
7021
+ async toInfo(row, hasTrace, traces) {
7022
+ const source = await this.sourceOf(row, hasTrace, traces);
6074
7023
  return {
6075
7024
  sessionId: row.sessionId,
6076
7025
  projectId: row.projectId,
@@ -6084,6 +7033,7 @@ var SessionService = class {
6084
7033
  createdAt: row.createdAt,
6085
7034
  status: this.deps.manager.statusOf(row.sessionId),
6086
7035
  pendingApprovalCount: this.deps.manager.pendingApprovalCount(row.sessionId),
7036
+ pendingFollowUpCount: this.deps.manager.pendingFollowUpCount(row.sessionId),
6087
7037
  hasTrace,
6088
7038
  archived: (row.archivedAt ?? null) !== null
6089
7039
  };
@@ -6095,11 +7045,13 @@ var SessionService = class {
6095
7045
  * session_meta is read once and cached. A Session with no Trace yet stays unknown and
6096
7046
  * is NOT cached negatively — its meta may appear with the first run.
6097
7047
  */
6098
- async sourceOf(row, hasTrace) {
7048
+ async sourceOf(row, hasTrace, traces) {
6099
7049
  const known = this.deps.sources.get(row.sessionId);
6100
7050
  if (known !== void 0) return known ?? void 0;
6101
7051
  if (!hasTrace) return void 0;
6102
- const meta = await this.readTraceMeta(row.projectId, row.agentId, row.sessionId);
7052
+ const location = traces?.get(row.sessionId) ?? (await this.discoverTraces(row.projectId, row.agentId)).get(row.sessionId);
7053
+ if (!location) return void 0;
7054
+ const meta = await this.readTraceMeta(location);
6103
7055
  if (!meta) return void 0;
6104
7056
  const source = asSessionSource(meta.payload.source) ?? null;
6105
7057
  this.deps.sources.set(row.sessionId, source);
@@ -6107,8 +7059,17 @@ var SessionService = class {
6107
7059
  }
6108
7060
  /** Whether this Session already has a Trace record (a Task has been run). */
6109
7061
  async hasTrace(row) {
6110
- const ids = await this.discoverTraceSessionIds(row.projectId, row.agentId);
6111
- return ids.has(row.sessionId);
7062
+ return (await this.discoverTraces(row.projectId, row.agentId)).has(row.sessionId);
7063
+ }
7064
+ /**
7065
+ * The list category of a row: archived wins (an explicit user action), then the
7066
+ * origin's bucket, and no/unknown source is `active` — the same precedence the
7067
+ * sidebar's partition applies to loaded rows, so server filtering and client
7068
+ * rendering can never disagree.
7069
+ */
7070
+ async categoryOf(row, hasTrace, traces) {
7071
+ if ((row.archivedAt ?? null) !== null) return "archived";
7072
+ return await this.sourceOf(row, hasTrace, traces) ?? "active";
6112
7073
  }
6113
7074
  /**
6114
7075
  * List: DB ∪ Trace directory discovery, sorted by createdAt descending. Optional
@@ -6116,22 +7077,58 @@ var SessionService = class {
6116
7077
  * more"); slicing happens before toInfo, so per-request source derivation (lazy
6117
7078
  * Trace-head reads) stays bounded by the page size. Discovery/adoption still scans
6118
7079
  * the whole directory — the union and global ordering need every id.
7080
+ *
7081
+ * `category` filters to one sidebar bucket **before** paging, so offset/limit page
7082
+ * within the category. Filtering needs each walked row's category (a possible
7083
+ * Trace-head read per row, cached in the sources registry); without `withCounts`
7084
+ * the walk stops as soon as the requested page is complete. `withCounts` classifies
7085
+ * every row and returns per-category totals over the whole list — plus the same
7086
+ * totals broken down by Workspace path — so the sidebar can label the collapsed
7087
+ * folders (and a workspace group can know its own share) without loading them.
6119
7088
  */
6120
- async listSessions(projectId, agentId, paging) {
6121
- const traceIds = await this.discoverTraceSessionIds(projectId, agentId);
7089
+ async listSessions(projectId, agentId, opts = {}) {
7090
+ const { paging, category, withCounts } = opts;
7091
+ const traces = await this.discoverTraces(projectId, agentId);
6122
7092
  const rows = new Map(
6123
7093
  this.deps.sessions.listByAgent(projectId, agentId).map((r) => [r.sessionId, r])
6124
7094
  );
6125
- for (const sessionId of traceIds) {
7095
+ for (const [sessionId, location] of traces) {
6126
7096
  if (rows.has(sessionId)) continue;
6127
- const discovered = await this.adoptTraceSession(projectId, agentId, sessionId);
7097
+ const discovered = await this.adoptTraceSession(projectId, agentId, sessionId, location);
6128
7098
  if (discovered) rows.set(sessionId, discovered);
6129
7099
  }
6130
7100
  const sorted = [...rows.values()].sort(
6131
7101
  (a, b) => b.createdAt.localeCompare(a.createdAt) || b.sessionId.localeCompare(a.sessionId)
6132
7102
  );
6133
- const page = paging ? sorted.slice(paging.offset, paging.offset + paging.limit) : sorted;
6134
- return Promise.all(page.map((row) => this.toInfo(row, traceIds.has(row.sessionId))));
7103
+ const toPage = (page) => Promise.all(page.map((row) => this.toInfo(row, traces.has(row.sessionId), traces)));
7104
+ if (category === void 0 && !withCounts) {
7105
+ return {
7106
+ sessions: await toPage(
7107
+ paging ? sorted.slice(paging.offset, paging.offset + paging.limit) : sorted
7108
+ )
7109
+ };
7110
+ }
7111
+ const want = paging ? paging.offset + paging.limit : Infinity;
7112
+ const counts = { active: 0, subagent: 0, schedule: 0, archived: 0 };
7113
+ const workspaceCounts = {};
7114
+ const matched = [];
7115
+ for (const row of sorted) {
7116
+ if (!withCounts && matched.length >= want) break;
7117
+ const cat = await this.categoryOf(row, traces.has(row.sessionId), traces);
7118
+ counts[cat] += 1;
7119
+ if (withCounts) {
7120
+ const ws = workspaceCounts[row.workspace] ??= {
7121
+ active: 0,
7122
+ subagent: 0,
7123
+ schedule: 0,
7124
+ archived: 0
7125
+ };
7126
+ ws[cat] += 1;
7127
+ }
7128
+ if ((category === void 0 || cat === category) && matched.length < want) matched.push(row);
7129
+ }
7130
+ const sessions = await toPage(paging ? matched.slice(paging.offset, want) : matched);
7131
+ return withCounts ? { sessions, counts, workspaceCounts } : { sessions };
6135
7132
  }
6136
7133
  /**
6137
7134
  * Session stats (Agents list card): total count = size of the union of DB index
@@ -6243,48 +7240,59 @@ var SessionService = class {
6243
7240
  this.deps.manager.adopt(row, session);
6244
7241
  return this.toInfo(row, false);
6245
7242
  }
6246
- /** Scans the Trace directory to get the set of session_ids with records. */
6247
- async discoverTraceSessionIds(projectId, agentId) {
6248
- const dir = tracesDir2(this.deps.root, projectId, agentId);
6249
- const ids = /* @__PURE__ */ new Set();
6250
- for (const dateDir of await listDirsSafe(dir)) {
6251
- for (const file of await listFilesSafe(path13.join(dir, dateDir))) {
6252
- const match = TRACE_FILE_RE.exec(file);
6253
- if (match) ids.add(match[1]);
6254
- }
6255
- }
6256
- return ids;
7243
+ /**
7244
+ * Absolute path of a Session's **latest** Trace file (the current context shard);
7245
+ * undefined when no Trace exists. Costs a directory walk, so only the single-session
7246
+ * GET includes it in the DTO (see SessionInfo.tracePath) — the web's `/model` switch
7247
+ * hands it to the new session's `[model_switch_from]` block so the model can read the
7248
+ * source history itself when it needs it.
7249
+ */
7250
+ async latestTracePath(row) {
7251
+ const located = await findLatestTraceFile2(
7252
+ tracesDir2(this.deps.root, row.projectId, row.agentId),
7253
+ row.sessionId
7254
+ );
7255
+ return located?.path;
6257
7256
  }
6258
7257
  /**
6259
- * session_meta from the earliest Trace shard of a Session (the shard whose head carries
6260
- * the original meta); null when there is no readable Trace or it has no meta. Shared by
6261
- * adoption backfill and lazy `source` resolution.
7258
+ * One walk over the Trace directory: session_id → its **earliest** shard (the shard
7259
+ * whose head carries the original session_meta). Discovery (which Sessions have
7260
+ * records) and the meta-read location come out of a single pass, so classifying every
7261
+ * row (`counts=1`) costs one directory walk total instead of one per Session.
6262
7262
  */
6263
- async readTraceMeta(projectId, agentId, sessionId) {
7263
+ async discoverTraces(projectId, agentId) {
6264
7264
  const dir = tracesDir2(this.deps.root, projectId, agentId);
6265
- let earliest = null;
7265
+ const out = /* @__PURE__ */ new Map();
6266
7266
  for (const dateDir of await listDirsSafe(dir)) {
6267
7267
  for (const file of await listFilesSafe(path13.join(dir, dateDir))) {
6268
7268
  const match = TRACE_FILE_RE.exec(file);
6269
- if (!match || match[1] !== sessionId) continue;
7269
+ if (!match) continue;
7270
+ const sessionId = match[1];
6270
7271
  const index = Number(match[2]);
6271
- if (!earliest || index < earliest.index) {
6272
- earliest = { path: path13.join(dir, dateDir, file), index };
7272
+ const cur = out.get(sessionId);
7273
+ if (!cur || index < cur.index) {
7274
+ out.set(sessionId, { path: path13.join(dir, dateDir, file), index });
6273
7275
  }
6274
7276
  }
6275
7277
  }
6276
- if (!earliest) return null;
7278
+ return out;
7279
+ }
7280
+ /**
7281
+ * session_meta from a located Trace shard head; null when unreadable or it has no
7282
+ * meta. Shared by adoption backfill and lazy `source` resolution.
7283
+ */
7284
+ async readTraceMeta(location) {
6277
7285
  let messages;
6278
7286
  try {
6279
- messages = await readTraceTolerant(earliest.path);
7287
+ messages = await readTraceHead(location.path);
6280
7288
  } catch {
6281
7289
  return null;
6282
7290
  }
6283
7291
  return messages.find(isSessionMeta4) ?? null;
6284
7292
  }
6285
7293
  /** Adopts a Session that exists only in the Trace directory: reads session_meta from the first line of the earliest index file. */
6286
- async adoptTraceSession(projectId, agentId, sessionId) {
6287
- const meta = await this.readTraceMeta(projectId, agentId, sessionId);
7294
+ async adoptTraceSession(projectId, agentId, sessionId, location) {
7295
+ const meta = await this.readTraceMeta(location);
6288
7296
  if (!meta) return null;
6289
7297
  if (typeof meta.payload.provider !== "string") return null;
6290
7298
  this.deps.sources.set(sessionId, asSessionSource(meta.payload.source) ?? null);
@@ -6328,8 +7336,16 @@ async function listFilesSafe(dir) {
6328
7336
  // src/services/trace-service.ts
6329
7337
  import fs15 from "fs/promises";
6330
7338
  import path14 from "path";
6331
- import { agentsDir as agentsDir3, readTraceTolerant as readTraceTolerant2, tracesDir as tracesDir3 } from "@prismshadow/penguin-core";
7339
+ import {
7340
+ agentsDir as agentsDir3,
7341
+ isSessionMeta as isSessionMeta5,
7342
+ parseTraceLines as parseTraceLines2,
7343
+ parseUserSteeringText,
7344
+ readTraceTolerant as readTraceTolerant2,
7345
+ tracesDir as tracesDir3
7346
+ } from "@prismshadow/penguin-core";
6332
7347
  var TRACE_FILE_RE2 = /^(.+)_(\d{3})\.jsonl$/;
7348
+ var IMPORT_SESSION_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
6333
7349
  var MAX_SUBAGENT_DEPTH = 4;
6334
7350
  function subagentPointer(msg) {
6335
7351
  if (msg.type !== "event_msg") return null;
@@ -6519,7 +7535,9 @@ var TraceService = class {
6519
7535
  const msg = messages[mi];
6520
7536
  const p = msg.payload;
6521
7537
  const hasOrigin = msg.origin !== void 0 && msg.origin.length > 0;
6522
- const startsUserTurn = !hasOrigin && !compactionActive && msg.type === "model_msg" && (p.type === "text" && p.role === "user" || p.type === "image_url");
7538
+ const isSteeringText = !hasOrigin && msg.type === "model_msg" && p.type === "text" && p.role === "user" && typeof p.text === "string" && parseUserSteeringText(p.text) !== null;
7539
+ if (isSteeringText) continuation = true;
7540
+ const startsUserTurn = !hasOrigin && !compactionActive && !isSteeringText && msg.type === "model_msg" && (p.type === "text" && p.role === "user" || p.type === "image_url");
6523
7541
  const startsCompactionTurn = !hasOrigin && msg.type === "event_msg" && p.type === "compaction_begin";
6524
7542
  if (startsUserTurn || startsCompactionTurn) {
6525
7543
  if (pendingFrom === null) pendingFrom = mi;
@@ -6737,6 +7755,10 @@ var TraceService = class {
6737
7755
  return out;
6738
7756
  }
6739
7757
  async readFileByIndex(projectId, agentId, sessionId, index) {
7758
+ const file = await this.locateByIndex(projectId, agentId, sessionId, index);
7759
+ return readTraceTolerant2(file.path);
7760
+ }
7761
+ async locateByIndex(projectId, agentId, sessionId, index) {
6740
7762
  const files = await this.locateAll(projectId, agentId, sessionId);
6741
7763
  const file = files.find((f) => f.index === index);
6742
7764
  if (!file) {
@@ -6746,7 +7768,165 @@ var TraceService = class {
6746
7768
  `This Session has no Trace file with index ${index}.`
6747
7769
  );
6748
7770
  }
6749
- return readTraceTolerant2(file.path);
7771
+ return file;
7772
+ }
7773
+ /** Raw bytes of the Trace file at the given index (export/download: the file is served verbatim). */
7774
+ async readFileRaw(projectId, agentId, sessionId, index) {
7775
+ const file = await this.locateByIndex(projectId, agentId, sessionId, index);
7776
+ return fs15.readFile(file.path);
7777
+ }
7778
+ /**
7779
+ * Imports an uploaded Trace file (raw JSONL content). Validates the content itself —
7780
+ * parseable JSONL whose first record is a `session_meta` carrying a filename-safe
7781
+ * `session_id` (400 invalid_trace otherwise) — then writes it under the Agent's traces
7782
+ * directory as a **new Session**: a session id the Agent already has is rejected with
7783
+ * 409 `trace_session_exists` (splicing a further index into an existing Session would
7784
+ * corrupt its concatenated transcript, silently become its resume point, and could
7785
+ * collide with a live Writer's rotation), so the imported file is always index 1. The
7786
+ * date dir comes from the first record's timestamp (falling back to now), formatted as
7787
+ * a **local** date — the same convention as core's Trace Writer — so an export →
7788
+ * import round-trip lands in the same date dir on non-UTC servers.
7789
+ */
7790
+ async importTraceFile(projectId, agentId, content) {
7791
+ const invalid = (message) => new HttpError(400, "invalid_trace", message);
7792
+ let records;
7793
+ try {
7794
+ records = parseTraceLines2(content);
7795
+ } catch {
7796
+ throw invalid("The file is not valid Trace JSONL.");
7797
+ }
7798
+ if (records.length === 0) throw invalid("The file contains no Trace records.");
7799
+ const first = records[0];
7800
+ if (!isSessionMeta5(first))
7801
+ throw invalid("The first record of a Trace file must be session_meta.");
7802
+ const sessionId = first.payload.session_id;
7803
+ if (typeof sessionId !== "string" || !IMPORT_SESSION_ID_RE.test(sessionId)) {
7804
+ throw invalid("session_meta carries a missing or invalid session_id.");
7805
+ }
7806
+ const duplicate = () => new HttpError(
7807
+ 409,
7808
+ "trace_session_exists",
7809
+ `This Agent already has a Session with id ${sessionId}; a duplicate Trace cannot be imported.`
7810
+ );
7811
+ if ((await this.locateAll(projectId, agentId, sessionId)).length > 0) throw duplicate();
7812
+ const ts = Date.parse(first.timestamp);
7813
+ const date = formatLocalDate(Number.isNaN(ts) ? /* @__PURE__ */ new Date() : new Date(ts));
7814
+ const index = 1;
7815
+ const dir = path14.join(tracesDir3(this.root, projectId, agentId), date);
7816
+ await fs15.mkdir(dir, { recursive: true });
7817
+ const file = path14.join(dir, `${sessionId}_${String(index).padStart(3, "0")}.jsonl`);
7818
+ try {
7819
+ await fs15.writeFile(file, content.replace(/\n+$/, "") + "\n", {
7820
+ encoding: "utf8",
7821
+ flag: "wx"
7822
+ });
7823
+ } catch (err) {
7824
+ if (err.code === "EEXIST") throw duplicate();
7825
+ throw err;
7826
+ }
7827
+ return { sessionId, index, date };
7828
+ }
7829
+ };
7830
+
7831
+ // src/services/update-check-service.ts
7832
+ import { BUILD_DATE as BUILD_DATE2, VERSION as VERSION2, compareVersions, normalizeVersion } from "@prismshadow/penguin-core";
7833
+ var REPO_SLUG = "Prism-Shadow/penguin-harness";
7834
+ var LATEST_RELEASE_API = `https://api.github.com/repos/${REPO_SLUG}/releases/latest`;
7835
+ var SUCCESS_TTL_MS = 60 * 60 * 1e3;
7836
+ var FAILURE_TTL_MS = 10 * 60 * 1e3;
7837
+ var UpdateCheckService = class {
7838
+ fetchImpl;
7839
+ now;
7840
+ env;
7841
+ cached = null;
7842
+ inflight = null;
7843
+ constructor(options = {}) {
7844
+ this.fetchImpl = options.fetchImpl ?? fetch;
7845
+ this.now = options.now ?? (() => /* @__PURE__ */ new Date());
7846
+ this.env = options.env ?? process.env;
7847
+ }
7848
+ /**
7849
+ * Never throws; never makes a network call when disabled or (without `force`) while
7850
+ * the cache is fresh. `force` — the web's manual "check for updates" action — skips
7851
+ * the freshness check and performs a real lookup even over a warm cache: the cache
7852
+ * exists to shield GitHub's unauthenticated rate limit from *passive* checks fired
7853
+ * by every sidebar open, and an explicit user click is rare enough to press through
7854
+ * it. Everything else is unchanged: the opt-out stays authoritative (force never
7855
+ * dials out under PENGUIN_UPDATE_CHECK=off), the outcome lands in the same cache
7856
+ * (subsequent passive checks reuse it), and concurrent callers — forced or not —
7857
+ * still share one in-flight lookup.
7858
+ */
7859
+ async check(force = false) {
7860
+ if (this.env["PENGUIN_UPDATE_CHECK"] === "off") {
7861
+ return {
7862
+ currentVersion: VERSION2,
7863
+ buildDate: BUILD_DATE2,
7864
+ latestVersion: null,
7865
+ updateAvailable: false,
7866
+ releaseUrl: null,
7867
+ publishedAt: null,
7868
+ checkedAt: this.now().toISOString(),
7869
+ disabled: true
7870
+ };
7871
+ }
7872
+ if (!force && this.cached && this.now().getTime() < this.cached.expiresAt) {
7873
+ return this.cached.response;
7874
+ }
7875
+ this.inflight ??= this.lookup().finally(() => {
7876
+ this.inflight = null;
7877
+ });
7878
+ return this.inflight;
7879
+ }
7880
+ /** One real lookup; stores the outcome (success or failure) in the cache with its TTL. */
7881
+ async lookup() {
7882
+ const response = await this.resolveLatest();
7883
+ const ttl = response.error === void 0 ? SUCCESS_TTL_MS : FAILURE_TTL_MS;
7884
+ this.cached = { response, expiresAt: this.now().getTime() + ttl };
7885
+ return response;
7886
+ }
7887
+ /**
7888
+ * Failure taxonomy mirrors the CLI's fetchLatestVersion: an unreachable endpoint or
7889
+ * timeout is "network"; a 403/429 is "rate_limited" (unauthenticated clients hit
7890
+ * GitHub's per-IP limit); any other non-2xx status, an unparsable body, or a body
7891
+ * without a usable tag_name is "bad_response".
7892
+ */
7893
+ async resolveLatest() {
7894
+ const checkedAt = this.now().toISOString();
7895
+ const base = { currentVersion: VERSION2, buildDate: BUILD_DATE2, checkedAt };
7896
+ const failure = (error) => ({
7897
+ ...base,
7898
+ latestVersion: null,
7899
+ updateAvailable: false,
7900
+ releaseUrl: null,
7901
+ publishedAt: null,
7902
+ error
7903
+ });
7904
+ let res;
7905
+ try {
7906
+ res = await this.fetchImpl(LATEST_RELEASE_API, {
7907
+ headers: { accept: "application/vnd.github+json", "user-agent": "penguin-server" },
7908
+ signal: AbortSignal.timeout(15e3)
7909
+ });
7910
+ } catch {
7911
+ return failure("network");
7912
+ }
7913
+ if (res.status === 403 || res.status === 429) return failure("rate_limited");
7914
+ if (!res.ok) return failure("bad_response");
7915
+ let body;
7916
+ try {
7917
+ body = await res.json();
7918
+ } catch {
7919
+ return failure("bad_response");
7920
+ }
7921
+ const tag = typeof body.tag_name === "string" ? normalizeVersion(body.tag_name) : "";
7922
+ if (tag === "") return failure("bad_response");
7923
+ return {
7924
+ ...base,
7925
+ latestVersion: tag,
7926
+ updateAvailable: compareVersions(tag, VERSION2) > 0,
7927
+ releaseUrl: typeof body.html_url === "string" ? body.html_url : null,
7928
+ publishedAt: typeof body.published_at === "string" ? body.published_at : null
7929
+ };
6750
7930
  }
6751
7931
  };
6752
7932
 
@@ -6918,6 +8098,53 @@ var UsageService = class {
6918
8098
  }
6919
8099
  };
6920
8100
 
8101
+ // src/http/routes/preview.ts
8102
+ import { Hono as Hono20 } from "hono";
8103
+ var DIRECTORY_INDEX = "index.html";
8104
+ function previewRoutes(deps2) {
8105
+ const app2 = new Hono20();
8106
+ app2.get("/:token/*", async (c) => {
8107
+ const token = c.req.param("token");
8108
+ const payload = token ? deps2.previewTokens.verify(token) : null;
8109
+ if (!payload) return c.text("Not found", 404);
8110
+ const authority = requestAuthority(c.req.url, c.req.header("host"));
8111
+ if (hostOnly(authority).toLowerCase() !== payload.host.toLowerCase()) {
8112
+ return c.text("Not found", 404);
8113
+ }
8114
+ const row = deps2.sessionsRepo.findById(payload.sessionId);
8115
+ if (!row) return c.text("Not found", 404);
8116
+ const prefix = `/preview/${token}/`;
8117
+ const raw = c.req.path.startsWith(prefix) ? c.req.path.slice(prefix.length) : "";
8118
+ let rel;
8119
+ try {
8120
+ rel = decodeURIComponent(raw);
8121
+ } catch {
8122
+ return c.text("Not found", 404);
8123
+ }
8124
+ if (rel === "" || rel.endsWith("/")) rel += DIRECTORY_INDEX;
8125
+ let file;
8126
+ try {
8127
+ file = await deps2.workspaceFiles.read(row.workspace, rel);
8128
+ } catch {
8129
+ return c.text("Not found", 404);
8130
+ }
8131
+ return new Response(new Uint8Array(file.data), {
8132
+ status: 200,
8133
+ headers: {
8134
+ "Content-Type": file.contentType,
8135
+ "X-Content-Type-Options": "nosniff",
8136
+ // Without this, third-party requests made by the page leak the token-bearing
8137
+ // URL through Referer — a risk that only exists because this origin exists to
8138
+ // let third-party embeds run.
8139
+ "Referrer-Policy": "no-referrer",
8140
+ // Previews are per-token and short-lived; never let a shared cache keep them.
8141
+ "Cache-Control": "no-store"
8142
+ }
8143
+ });
8144
+ });
8145
+ return app2;
8146
+ }
8147
+
6921
8148
  // src/app.ts
6922
8149
  var MAX_BODY_BYTES = 20 * 1024 * 1024;
6923
8150
  function buildAppDeps(config2, overrides = {}) {
@@ -6933,11 +8160,13 @@ function buildAppDeps(config2, overrides = {}) {
6933
8160
  const errorsRepo = new ErrorsRepo(db);
6934
8161
  const prefsRepo = new UiPrefsRepo(db);
6935
8162
  const schedulesRepo = new SchedulesRepo(db);
8163
+ const goalsRepo = new GoalsRepo(db);
6936
8164
  const projectConfigService = new ProjectConfigService(config2.root);
6937
8165
  const agentConfigService = new AgentConfigService(config2.root);
6938
8166
  const agentService = new AgentService(config2.root, agentsRepo, agentConfigService);
6939
8167
  const traceService = new TraceService(config2.root);
6940
8168
  const workspaceFiles = new WorkspaceFilesService();
8169
+ const previewTokens = createPreviewTokenSigner();
6941
8170
  const benchmarks = new BenchmarkService(config2.root);
6942
8171
  const snapshots = new SnapshotService(config2.root);
6943
8172
  const usageService = new UsageService(
@@ -6946,6 +8175,7 @@ function buildAppDeps(config2, overrides = {}) {
6946
8175
  (projectId, provider, modelId) => projectConfigService.getPricing(projectId, provider, modelId),
6947
8176
  overrides.now ?? (() => /* @__PURE__ */ new Date())
6948
8177
  );
8178
+ const updateCheck = overrides.updateCheck ?? new UpdateCheckService(overrides.now ? { now: overrides.now } : {});
6949
8179
  let managerRef;
6950
8180
  const channels = new ChannelHub({
6951
8181
  isActive: (key) => managerRef !== void 0 && managerRef.statusOf(key) !== "idle"
@@ -6962,7 +8192,8 @@ function buildAppDeps(config2, overrides = {}) {
6962
8192
  recorder,
6963
8193
  errors,
6964
8194
  titles,
6965
- log
8195
+ log,
8196
+ goals: goalsRepo
6966
8197
  });
6967
8198
  managerRef = manager;
6968
8199
  const projectService = new ProjectService({
@@ -6975,6 +8206,7 @@ function buildAppDeps(config2, overrides = {}) {
6975
8206
  usage: usageRepo,
6976
8207
  errors: errorsRepo,
6977
8208
  schedules: schedulesRepo,
8209
+ goals: goalsRepo,
6978
8210
  projectConfig: projectConfigService,
6979
8211
  manager
6980
8212
  });
@@ -7027,10 +8259,14 @@ function buildAppDeps(config2, overrides = {}) {
7027
8259
  sessionService,
7028
8260
  traceService,
7029
8261
  usageService,
8262
+ updateCheck,
7030
8263
  workspaceFiles,
8264
+ previewTokens,
7031
8265
  benchmarks,
7032
8266
  snapshots,
7033
8267
  schedulesRepo,
8268
+ goalsRepo,
8269
+ errorsRepo,
7034
8270
  scheduler,
7035
8271
  channels,
7036
8272
  manager,
@@ -7040,7 +8276,7 @@ function buildAppDeps(config2, overrides = {}) {
7040
8276
  };
7041
8277
  }
7042
8278
  function createApp(deps2) {
7043
- const app2 = new Hono19();
8279
+ const app2 = new Hono21();
7044
8280
  app2.onError((err, c) => {
7045
8281
  const projectId = attributedProjectId(c, deps2);
7046
8282
  deps2.errors.record({
@@ -7057,6 +8293,21 @@ function createApp(deps2) {
7057
8293
  const ms = Math.round(performance.now() - start);
7058
8294
  deps2.log(`${c.req.method} ${c.req.path} ${c.res.status} ${ms}ms`);
7059
8295
  });
8296
+ const previewRoles = deps2.config.previewOrigin ? null : loopbackHostRoles(deps2.config.host);
8297
+ if (previewRoles) {
8298
+ app2.use("*", async (c, next) => {
8299
+ const host = hostOnly(requestAuthority(c.req.url, c.req.header("host"))).toLowerCase();
8300
+ if (host === previewRoles.preview && !c.req.path.startsWith("/preview/")) {
8301
+ if (c.req.path.startsWith("/api/")) {
8302
+ throw new HttpError(401, "unauthorized", "The API is not served on the preview host.");
8303
+ }
8304
+ const url = new URL(c.req.url);
8305
+ url.hostname = previewRoles.app;
8306
+ return c.redirect(url.toString(), 302);
8307
+ }
8308
+ await next();
8309
+ });
8310
+ }
7060
8311
  app2.use("/api/*", async (c, next) => {
7061
8312
  const contentLength = Number(c.req.header("content-length") ?? 0);
7062
8313
  if (contentLength > MAX_BODY_BYTES) {
@@ -7069,6 +8320,7 @@ function createApp(deps2) {
7069
8320
  const auth = authMiddleware(deps2.authService);
7070
8321
  app2.use("/api/*", auth);
7071
8322
  app2.route("/api/me", meRoutes(deps2));
8323
+ app2.route("/api/version", versionRoutes(deps2));
7072
8324
  app2.route("/api/admin/users", adminUsersRoutes(deps2));
7073
8325
  app2.route("/api/events", eventsRoutes(deps2));
7074
8326
  app2.route("/api/skills", skillLibraryRoutes());
@@ -7087,6 +8339,7 @@ function createApp(deps2) {
7087
8339
  app2.route("/api/projects/:projectId/agents/:agentId/sessions", agentSessionsRoutes(deps2));
7088
8340
  app2.route("/api/projects/:projectId/usage", usageRoutes(deps2));
7089
8341
  app2.route("/api/sessions", sessionsRoutes(deps2));
8342
+ app2.route("/preview", previewRoutes(deps2));
7090
8343
  if (fs16.existsSync(deps2.config.webDist)) {
7091
8344
  registerStaticRoutes(app2, deps2.config.webDist);
7092
8345
  }
@@ -7160,6 +8413,19 @@ function defaultWebDist() {
7160
8413
  if (fs17.existsSync(bundled)) return bundled;
7161
8414
  return path16.resolve(here, "..", "..", "web", "dist");
7162
8415
  }
8416
+ function normalizePreviewOrigin(raw) {
8417
+ if (!raw || raw.trim() === "") return null;
8418
+ let url;
8419
+ try {
8420
+ url = new URL(raw.trim());
8421
+ } catch {
8422
+ throw new Error(`Invalid PENGUIN_PREVIEW_ORIGIN=${raw} (expected an absolute origin)`);
8423
+ }
8424
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
8425
+ throw new Error(`Invalid PENGUIN_PREVIEW_ORIGIN=${raw} (only http/https are supported)`);
8426
+ }
8427
+ return url.origin;
8428
+ }
7163
8429
  function resolveServerConfig(env = process.env) {
7164
8430
  const root = env.PENGUIN_HOME ?? resolveRoot();
7165
8431
  const port = Number(env.PORT || DEFAULT_SERVER_PORT);
@@ -7172,6 +8438,7 @@ function resolveServerConfig(env = process.env) {
7172
8438
  port,
7173
8439
  dbPath: env.PENGUIN_WEB_DB ?? path16.join(root, "web.db"),
7174
8440
  webDist: env.PENGUIN_WEB_DIST ?? defaultWebDist(),
8441
+ previewOrigin: normalizePreviewOrigin(env.PENGUIN_PREVIEW_ORIGIN),
7175
8442
  authSessionTtlMs: 7 * DAY_MS,
7176
8443
  authSessionRenewMs: 6 * DAY_MS
7177
8444
  };
@@ -7184,11 +8451,19 @@ var deps = buildAppDeps(config);
7184
8451
  var app = createApp(deps);
7185
8452
  await deps.authService.seedAdmin();
7186
8453
  await deps.scheduler.start();
8454
+ deps.goalsRepo.abortOrphanedActive();
8455
+ var appHost = loopbackHostRoles(config.host)?.app ?? config.host;
7187
8456
  var server = serve({ fetch: app.fetch, hostname: config.host, port: config.port }, (info) => {
7188
- console.log(`penguin-server started: http://${config.host}:${info.port}`);
8457
+ console.log(`penguin-server started: http://${appHost}:${info.port}`);
7189
8458
  console.log(`Data root: ${config.root}`);
7190
8459
  console.log(`SQLite: ${config.dbPath}`);
7191
8460
  });
8461
+ var ipv6Loopback = config.host === "127.0.0.1" || config.host === "localhost" ? serve({ fetch: app.fetch, hostname: "::1", port: config.port }) : null;
8462
+ ipv6Loopback?.on("error", (err) => {
8463
+ console.warn(
8464
+ `[server] IPv6 loopback listener unavailable (${err.code ?? err.message}); previews via localhost may not resolve.`
8465
+ );
8466
+ });
7192
8467
  var shuttingDown = false;
7193
8468
  async function shutdown(signal, exitCode = 0) {
7194
8469
  if (shuttingDown) return;
@@ -7197,6 +8472,7 @@ async function shutdown(signal, exitCode = 0) {
7197
8472
  deps.scheduler.stop();
7198
8473
  await deps.manager.shutdown(5e3);
7199
8474
  deps.channels.dispose();
8475
+ ipv6Loopback?.close();
7200
8476
  server.close(() => {
7201
8477
  deps.db.close();
7202
8478
  process.exit(exitCode);