@prismshadow/penguin-server 0.1.2 → 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 Hono20 } 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();
@@ -1514,6 +1604,12 @@ function membersRoutes(deps2) {
1514
1604
 
1515
1605
  // src/http/routes/models.ts
1516
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
+ }
1517
1613
  function parseRef(value, label) {
1518
1614
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
1519
1615
  throw badRequest(`${label} must be a { provider, modelId } object.`);
@@ -1633,7 +1729,10 @@ function modelsRoutes(deps2) {
1633
1729
  const projectId = requireValidId(c, "projectId");
1634
1730
  deps2.projectService.requireProjectOwner(c.var.user.userId, projectId);
1635
1731
  const req = parseModelsUpdate(await readJson(c));
1636
- 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);
1637
1736
  });
1638
1737
  app2.post("/test", async (c) => {
1639
1738
  const projectId = requireValidId(c, "projectId");
@@ -2305,6 +2404,9 @@ function agentsRoutes(deps2) {
2305
2404
  await settleWithin(runnings, 5e3);
2306
2405
  await deps2.agentService.deleteAgent(projectId, agentId);
2307
2406
  deps2.sessionsRepo.deleteByAgent(projectId, agentId);
2407
+ deps2.goalsRepo.deleteByAgent(projectId, agentId);
2408
+ deps2.schedulesRepo.deleteByAgent(projectId, agentId);
2409
+ deps2.errorsRepo.deleteByAgent(projectId, agentId);
2308
2410
  } finally {
2309
2411
  deps2.manager.endAgentDeletion(projectId, agentId);
2310
2412
  }
@@ -2410,6 +2512,7 @@ function agentConfigRoutes(deps2) {
2410
2512
 
2411
2513
  // src/http/routes/agent-traces.ts
2412
2514
  import { Hono as Hono16 } from "hono";
2515
+ var MAX_TRACE_BYTES = 14 * 1024 * 1024;
2413
2516
  function agentTracesRoutes(deps2) {
2414
2517
  const app2 = new Hono16();
2415
2518
  app2.get("/", async (c) => {
@@ -2437,6 +2540,41 @@ function agentTracesRoutes(deps2) {
2437
2540
  const index = positiveIntParam(c, "index");
2438
2541
  return c.json(await deps2.traceService.analyze(projectId, agentId, sessionId, index));
2439
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
+ });
2440
2578
  return app2;
2441
2579
  }
2442
2580
 
@@ -2735,6 +2873,10 @@ var WorkspaceFilesService = class {
2735
2873
  }
2736
2874
  const { dir, name } = await this.resolveWriteParent(workspace, rel);
2737
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
+ }
2738
2880
  const flags = fsc.O_WRONLY | fsc.O_CREAT | fsc.O_TRUNC | (fsc.O_NOFOLLOW ?? 0);
2739
2881
  let handle;
2740
2882
  try {
@@ -2798,6 +2940,18 @@ function parseTaskInput(body) {
2798
2940
  throw badRequest(`input[${i}].type must be one of text / image_url.`);
2799
2941
  });
2800
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
+ }
2801
2955
  function agentSessionsRoutes(deps2) {
2802
2956
  const app2 = new Hono18();
2803
2957
  app2.get("/", async (c) => {
@@ -2952,6 +3106,7 @@ function sessionsRoutes(deps2) {
2952
3106
  { recursive: true, force: true }
2953
3107
  );
2954
3108
  deps2.sessionsRepo.deleteById(row.sessionId);
3109
+ deps2.goalsRepo.deleteBySession(row.sessionId);
2955
3110
  deps2.sessionSources.delete(row.sessionId);
2956
3111
  } finally {
2957
3112
  deps2.manager.endSessionDeletion(row.sessionId);
@@ -2990,12 +3145,19 @@ function sessionsRoutes(deps2) {
2990
3145
  });
2991
3146
  app2.get("/:sessionId/messages", async (c) => {
2992
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
+ }
2993
3155
  const messages = await deps2.traceService.readMessages(
2994
3156
  row.projectId,
2995
3157
  row.agentId,
2996
3158
  row.sessionId
2997
3159
  );
2998
- return c.json({ messages });
3160
+ return c.json({ messages, ...live !== void 0 ? { live } : {} });
2999
3161
  });
3000
3162
  app2.get("/:sessionId/stream", (c) => {
3001
3163
  const row = resolveSession(c);
@@ -3017,8 +3179,22 @@ function sessionsRoutes(deps2) {
3017
3179
  app2.post("/:sessionId/tasks", async (c) => {
3018
3180
  const row = resolveSession(c);
3019
3181
  const body = await readJson(c);
3020
- const input = parseTaskInput(body);
3182
+ const goal = parseGoalField(body);
3021
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);
3022
3198
  const queueIfBusy = body.queueIfBusy === true;
3023
3199
  const { sessionId, queued } = await deps2.manager.startTask(row.sessionId, input, {
3024
3200
  ...thinkingLevel !== void 0 ? { thinkingLevel } : {},
@@ -3034,6 +3210,20 @@ function sessionsRoutes(deps2) {
3034
3210
  deps2.manager.steer(row.sessionId, text);
3035
3211
  return c.body(null, 202);
3036
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
+ });
3226
+ });
3037
3227
  app2.post("/:sessionId/approvals/:toolCallId", async (c) => {
3038
3228
  const row = resolveSession(c);
3039
3229
  const body = await readJson(c);
@@ -3053,6 +3243,11 @@ function sessionsRoutes(deps2) {
3053
3243
  const aborted = deps2.manager.abortTask(row.sessionId);
3054
3244
  return c.body(null, aborted ? 202 : 204);
3055
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
+ });
3056
3251
  app2.post("/:sessionId/compact", async (c) => {
3057
3252
  const row = resolveSession(c);
3058
3253
  const { sessionId } = await deps2.manager.startCompact(row.sessionId);
@@ -3167,6 +3362,104 @@ function sessionsRoutes(deps2) {
3167
3362
  return app2;
3168
3363
  }
3169
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
+
3170
3463
  // src/runtime/channel.ts
3171
3464
  import { randomUUID } from "crypto";
3172
3465
  var DEFAULT_MAX_COUNT = 1e4;
@@ -3191,6 +3484,16 @@ var Channel = class {
3191
3484
  get subscriberCount() {
3192
3485
  return this.listeners.size;
3193
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
+ }
3194
3497
  /** Broadcast an event: number it, buffer it (evicting the oldest), notify all subscribers. */
3195
3498
  publish(data, event) {
3196
3499
  const entry = this.makeEvent(data, event);
@@ -3363,7 +3666,11 @@ import path8 from "path";
3363
3666
  import {
3364
3667
  createAgent,
3365
3668
  findLatestTraceFile,
3669
+ goalFinishedOf,
3670
+ goalTokenDelta,
3671
+ isGoalRoundInput,
3366
3672
  isSessionMeta as isSessionMeta2,
3673
+ stripLeadingMarkerBlocks,
3367
3674
  tracesDir
3368
3675
  } from "@prismshadow/penguin-core";
3369
3676
 
@@ -3434,6 +3741,165 @@ function makeApprove(args) {
3434
3741
  };
3435
3742
  }
3436
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
+
3437
3903
  // src/runtime/session-sources.ts
3438
3904
  function asSessionSource(v) {
3439
3905
  return v === "schedule" || v === "subagent" ? v : void 0;
@@ -3461,6 +3927,9 @@ var TOOL_NAMES_MAX = 1e3;
3461
3927
  var ORIGIN_CTX_MAX = 200;
3462
3928
  var LLM_FAILURES = {
3463
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)." },
3464
3933
  timeout: {
3465
3934
  code: "llm_timeout",
3466
3935
  kind: "expected",
@@ -3473,7 +3942,7 @@ var LLM_FAILURES = {
3473
3942
  }
3474
3943
  };
3475
3944
  function isLlmFailure(s) {
3476
- return s === "failed" || s === "timeout" || s === "malformed";
3945
+ return s === "failed" || s === "timeout" || s === "malformed" || s === "auth";
3477
3946
  }
3478
3947
  function isToolFailure(s) {
3479
3948
  return s === "failed" || s === "timeout";
@@ -3497,7 +3966,7 @@ var StreamErrorWatcher = class {
3497
3966
  }
3498
3967
  errors;
3499
3968
  ctx;
3500
- /** 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). */
3501
3970
  pending = /* @__PURE__ */ new Map();
3502
3971
  /** Names of in-flight tool calls: `origin \0 tool_call_id` → tool name (dequeued once output arrives). */
3503
3972
  toolNames = /* @__PURE__ */ new Map();
@@ -3561,7 +4030,14 @@ var StreamErrorWatcher = class {
3561
4030
  const key = originKey(msg);
3562
4031
  if (p.type === "request_end") {
3563
4032
  this.flush(key);
3564
- 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
+ }
3565
4041
  return;
3566
4042
  }
3567
4043
  if (p.type === "request_begin") {
@@ -3579,12 +4055,12 @@ var StreamErrorWatcher = class {
3579
4055
  * up from it (see file header).
3580
4056
  */
3581
4057
  flush(key, reason) {
3582
- const status = this.pending.get(key);
3583
- if (status === void 0) return;
4058
+ const entry = this.pending.get(key);
4059
+ if (entry === void 0) return;
3584
4060
  this.pending.delete(key);
3585
- const spec = LLM_FAILURES[status];
4061
+ const spec = LLM_FAILURES[entry.status];
3586
4062
  const trimmed = reason?.trim();
3587
- const message = trimmed && !isUserAbortReason(trimmed) ? trimmed : spec.text;
4063
+ const message = trimmed && !isUserAbortReason(trimmed) ? trimmed : entry.message ?? spec.text;
3588
4064
  this.errors.record({
3589
4065
  source: "llm",
3590
4066
  err: message,
@@ -3748,6 +4224,8 @@ var SessionManager = class {
3748
4224
  deletingSessions = /* @__PURE__ */ new Set();
3749
4225
  /** Per-Agent config generation (key = agentKey), bumped by invalidateAgentRuntimes on vault updates. */
3750
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();
3751
4229
  sweepTimer;
3752
4230
  // —— Query surface (used by Session listing / Agent active-count / SSE subscription replay) ——
3753
4231
  statusOf(sessionId) {
@@ -3763,6 +4241,15 @@ var SessionManager = class {
3763
4241
  pendingFollowUpCount(sessionId) {
3764
4242
  return this.entries.get(sessionId)?.followUps.length ?? 0;
3765
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
+ }
3766
4253
  /** Number of Sessions for this Agent that are currently running / compacting. */
3767
4254
  activeCountForAgent(projectId, agentId) {
3768
4255
  let n = 0;
@@ -3802,6 +4289,21 @@ var SessionManager = class {
3802
4289
  const key = agentKey(projectId, agentId);
3803
4290
  this.agentGenerations.set(key, this.generationOf(projectId, agentId) + 1);
3804
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
+ }
3805
4307
  // —— Task / compaction drive ——
3806
4308
  /**
3807
4309
  * Start a Task: get-or-load → 409
@@ -3834,6 +4336,135 @@ var SessionManager = class {
3834
4336
  return { sessionId: entry.sessionId, queued: false };
3835
4337
  });
3836
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();
4357
+ const ac = new AbortController();
4358
+ entry.status = "running";
4359
+ entry.abort = ac;
4360
+ entry.lastActivityMs = Date.now();
4361
+ this.publishState(entry, "running");
4362
+ const approve = makeApprove({
4363
+ getMode: () => this.deps.sessions.findById(entry.sessionId)?.approvalMode ?? "always-ask",
4364
+ toolPermission: (name) => entry.session.toolPermission(name),
4365
+ registry: entry.approvals,
4366
+ publishRequest: (pending) => this.publishEvent(entry, {
4367
+ type: "approval_request",
4368
+ toolCall: pending.toolCall,
4369
+ ...pending.origin !== void 0 ? { origin: pending.origin } : {}
4370
+ })
4371
+ });
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 });
4394
+ return { sessionId: entry.sessionId };
4395
+ });
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
+ }
3837
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. */
3838
4469
  launchTask(entry, input, thinkingLevel) {
3839
4470
  const channel = this.deps.channels.get(entry.sessionId);
@@ -3929,6 +4560,21 @@ var SessionManager = class {
3929
4560
  }
3930
4561
  entry.lastActivityMs = Date.now();
3931
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
+ }
3932
4578
  /**
3933
4579
  * Interrupt the current Task/compaction: pending approvals converge to deny first,
3934
4580
  * then the AbortSignal fires. Returns false if nothing is in progress (the route
@@ -4204,6 +4850,7 @@ var SessionManager = class {
4204
4850
  }
4205
4851
  }
4206
4852
  }
4853
+ this.liveTail.observe(entry.sessionId, msg);
4207
4854
  this.deps.channels.get(entry.sessionId).publish(msg);
4208
4855
  watcher?.observe(msg);
4209
4856
  try {
@@ -4220,6 +4867,7 @@ var SessionManager = class {
4220
4867
  this.deps.errors?.record({ source: "session", err, ctx, code: "session_run_failed" });
4221
4868
  } finally {
4222
4869
  watcher?.close();
4870
+ this.liveTail.clear(entry.sessionId);
4223
4871
  entry.approvals.denyAll();
4224
4872
  entry.status = "idle";
4225
4873
  entry.abort = null;
@@ -4890,6 +5538,19 @@ var ProjectConfigService = class {
4890
5538
  filePath(projectId) {
4891
5539
  return projectConfigPath(this.root, projectId);
4892
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
+ }
4893
5554
  /** Reads the raw TOML object; returns an empty object if the file doesn't exist (does not write to disk). */
4894
5555
  async readRaw(projectId) {
4895
5556
  let raw;
@@ -5107,9 +5768,11 @@ var ProjectConfigService = class {
5107
5768
  provider: ref.provider,
5108
5769
  modelId: ref.model_id
5109
5770
  });
5771
+ const updatedAt = await this.configUpdatedAt(projectId);
5110
5772
  return {
5111
5773
  ...defaultRef !== void 0 ? { defaultModel: toDto(defaultRef) } : {},
5112
5774
  ...visionRef !== void 0 ? { visionModel: toDto(visionRef) } : {},
5775
+ ...updatedAt !== void 0 ? { updatedAt } : {},
5113
5776
  models
5114
5777
  };
5115
5778
  }
@@ -6231,6 +6894,7 @@ var ProjectService = class {
6231
6894
  this.deps.usage.deleteByProject(projectId);
6232
6895
  this.deps.errors.deleteByProject(projectId);
6233
6896
  this.deps.schedules.deleteByProject(projectId);
6897
+ this.deps.goals.deleteByProject(projectId);
6234
6898
  await fs14.rm(projectDir(this.deps.root, projectId), { recursive: true, force: true });
6235
6899
  }
6236
6900
  // —— Member authorization ——
@@ -6674,11 +7338,14 @@ import fs15 from "fs/promises";
6674
7338
  import path14 from "path";
6675
7339
  import {
6676
7340
  agentsDir as agentsDir3,
7341
+ isSessionMeta as isSessionMeta5,
7342
+ parseTraceLines as parseTraceLines2,
6677
7343
  parseUserSteeringText,
6678
7344
  readTraceTolerant as readTraceTolerant2,
6679
7345
  tracesDir as tracesDir3
6680
7346
  } from "@prismshadow/penguin-core";
6681
7347
  var TRACE_FILE_RE2 = /^(.+)_(\d{3})\.jsonl$/;
7348
+ var IMPORT_SESSION_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
6682
7349
  var MAX_SUBAGENT_DEPTH = 4;
6683
7350
  function subagentPointer(msg) {
6684
7351
  if (msg.type !== "event_msg") return null;
@@ -7088,6 +7755,10 @@ var TraceService = class {
7088
7755
  return out;
7089
7756
  }
7090
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) {
7091
7762
  const files = await this.locateAll(projectId, agentId, sessionId);
7092
7763
  const file = files.find((f) => f.index === index);
7093
7764
  if (!file) {
@@ -7097,7 +7768,165 @@ var TraceService = class {
7097
7768
  `This Session has no Trace file with index ${index}.`
7098
7769
  );
7099
7770
  }
7100
- 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
+ };
7101
7930
  }
7102
7931
  };
7103
7932
 
@@ -7270,10 +8099,10 @@ var UsageService = class {
7270
8099
  };
7271
8100
 
7272
8101
  // src/http/routes/preview.ts
7273
- import { Hono as Hono19 } from "hono";
8102
+ import { Hono as Hono20 } from "hono";
7274
8103
  var DIRECTORY_INDEX = "index.html";
7275
8104
  function previewRoutes(deps2) {
7276
- const app2 = new Hono19();
8105
+ const app2 = new Hono20();
7277
8106
  app2.get("/:token/*", async (c) => {
7278
8107
  const token = c.req.param("token");
7279
8108
  const payload = token ? deps2.previewTokens.verify(token) : null;
@@ -7331,6 +8160,7 @@ function buildAppDeps(config2, overrides = {}) {
7331
8160
  const errorsRepo = new ErrorsRepo(db);
7332
8161
  const prefsRepo = new UiPrefsRepo(db);
7333
8162
  const schedulesRepo = new SchedulesRepo(db);
8163
+ const goalsRepo = new GoalsRepo(db);
7334
8164
  const projectConfigService = new ProjectConfigService(config2.root);
7335
8165
  const agentConfigService = new AgentConfigService(config2.root);
7336
8166
  const agentService = new AgentService(config2.root, agentsRepo, agentConfigService);
@@ -7345,6 +8175,7 @@ function buildAppDeps(config2, overrides = {}) {
7345
8175
  (projectId, provider, modelId) => projectConfigService.getPricing(projectId, provider, modelId),
7346
8176
  overrides.now ?? (() => /* @__PURE__ */ new Date())
7347
8177
  );
8178
+ const updateCheck = overrides.updateCheck ?? new UpdateCheckService(overrides.now ? { now: overrides.now } : {});
7348
8179
  let managerRef;
7349
8180
  const channels = new ChannelHub({
7350
8181
  isActive: (key) => managerRef !== void 0 && managerRef.statusOf(key) !== "idle"
@@ -7361,7 +8192,8 @@ function buildAppDeps(config2, overrides = {}) {
7361
8192
  recorder,
7362
8193
  errors,
7363
8194
  titles,
7364
- log
8195
+ log,
8196
+ goals: goalsRepo
7365
8197
  });
7366
8198
  managerRef = manager;
7367
8199
  const projectService = new ProjectService({
@@ -7374,6 +8206,7 @@ function buildAppDeps(config2, overrides = {}) {
7374
8206
  usage: usageRepo,
7375
8207
  errors: errorsRepo,
7376
8208
  schedules: schedulesRepo,
8209
+ goals: goalsRepo,
7377
8210
  projectConfig: projectConfigService,
7378
8211
  manager
7379
8212
  });
@@ -7426,11 +8259,14 @@ function buildAppDeps(config2, overrides = {}) {
7426
8259
  sessionService,
7427
8260
  traceService,
7428
8261
  usageService,
8262
+ updateCheck,
7429
8263
  workspaceFiles,
7430
8264
  previewTokens,
7431
8265
  benchmarks,
7432
8266
  snapshots,
7433
8267
  schedulesRepo,
8268
+ goalsRepo,
8269
+ errorsRepo,
7434
8270
  scheduler,
7435
8271
  channels,
7436
8272
  manager,
@@ -7440,7 +8276,7 @@ function buildAppDeps(config2, overrides = {}) {
7440
8276
  };
7441
8277
  }
7442
8278
  function createApp(deps2) {
7443
- const app2 = new Hono20();
8279
+ const app2 = new Hono21();
7444
8280
  app2.onError((err, c) => {
7445
8281
  const projectId = attributedProjectId(c, deps2);
7446
8282
  deps2.errors.record({
@@ -7484,6 +8320,7 @@ function createApp(deps2) {
7484
8320
  const auth = authMiddleware(deps2.authService);
7485
8321
  app2.use("/api/*", auth);
7486
8322
  app2.route("/api/me", meRoutes(deps2));
8323
+ app2.route("/api/version", versionRoutes(deps2));
7487
8324
  app2.route("/api/admin/users", adminUsersRoutes(deps2));
7488
8325
  app2.route("/api/events", eventsRoutes(deps2));
7489
8326
  app2.route("/api/skills", skillLibraryRoutes());
@@ -7614,6 +8451,7 @@ var deps = buildAppDeps(config);
7614
8451
  var app = createApp(deps);
7615
8452
  await deps.authService.seedAdmin();
7616
8453
  await deps.scheduler.start();
8454
+ deps.goalsRepo.abortOrphanedActive();
7617
8455
  var appHost = loopbackHostRoles(config.host)?.app ?? config.host;
7618
8456
  var server = serve({ fetch: app.fetch, hostname: config.host, port: config.port }, (info) => {
7619
8457
  console.log(`penguin-server started: http://${appHost}:${info.port}`);