@prismshadow/penguin-server 0.1.1 → 0.1.2

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 Hono20 } from "hono";
10
10
 
11
11
  // src/db/database.ts
12
12
  import { mkdirSync } from "fs";
@@ -1154,6 +1154,12 @@ function optionalNumber(obj, key, rule = {}) {
1154
1154
  if (rule.nonNegative && v < 0) throw badRequest(`${label} must not be negative.`);
1155
1155
  return v;
1156
1156
  }
1157
+ function optionalBoolean(obj, key, label = key) {
1158
+ const v = obj[key];
1159
+ if (v === void 0) return void 0;
1160
+ if (typeof v !== "boolean") throw badRequest(`${label} must be a boolean.`);
1161
+ return v;
1162
+ }
1157
1163
  function optionalDateParam(value, label) {
1158
1164
  if (value === void 0 || value === "") return void 0;
1159
1165
  const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
@@ -1233,10 +1239,111 @@ function authRoutes(deps2) {
1233
1239
 
1234
1240
  // src/http/routes/me.ts
1235
1241
  import { Hono as Hono3 } from "hono";
1242
+
1243
+ // src/services/preview-token.ts
1244
+ import { createHmac, randomBytes as randomBytes3, timingSafeEqual as timingSafeEqual2 } from "crypto";
1245
+ var PREVIEW_TOKEN_TTL_MS = 10 * 60 * 1e3;
1246
+ function b64url(buf) {
1247
+ return buf.toString("base64url");
1248
+ }
1249
+ function createPreviewTokenSigner(secret = randomBytes3(32)) {
1250
+ const mac = (body) => createHmac("sha256", secret).update(body).digest();
1251
+ return {
1252
+ sign(payload) {
1253
+ const body = b64url(Buffer.from(JSON.stringify(payload), "utf8"));
1254
+ return `${body}.${b64url(mac(body))}`;
1255
+ },
1256
+ verify(token) {
1257
+ const dot = token.indexOf(".");
1258
+ if (dot <= 0 || dot === token.length - 1) return null;
1259
+ const body = token.slice(0, dot);
1260
+ const provided = Buffer.from(token.slice(dot + 1), "base64url");
1261
+ const expected = mac(body);
1262
+ if (provided.length !== expected.length || !timingSafeEqual2(provided, expected)) return null;
1263
+ let payload;
1264
+ try {
1265
+ payload = JSON.parse(
1266
+ Buffer.from(body, "base64url").toString("utf8")
1267
+ );
1268
+ } catch {
1269
+ return null;
1270
+ }
1271
+ if (typeof payload?.sessionId !== "string" || typeof payload?.host !== "string" || typeof payload?.expiresAt !== "number") {
1272
+ return null;
1273
+ }
1274
+ if (Date.now() >= payload.expiresAt) return null;
1275
+ return payload;
1276
+ }
1277
+ };
1278
+ }
1279
+ function hostOnly(hostHeader) {
1280
+ const trimmed = hostHeader.trim();
1281
+ if (trimmed.startsWith("[")) {
1282
+ const end = trimmed.indexOf("]");
1283
+ return end === -1 ? trimmed : trimmed.slice(0, end + 1);
1284
+ }
1285
+ const colon = trimmed.lastIndexOf(":");
1286
+ return colon === -1 ? trimmed : trimmed.slice(0, colon);
1287
+ }
1288
+ function loopbackCounterpart(host) {
1289
+ const h = host.toLowerCase();
1290
+ if (h === "127.0.0.1") return "localhost";
1291
+ if (h === "localhost") return "127.0.0.1";
1292
+ if (h === "[::1]") return "127.0.0.1";
1293
+ return null;
1294
+ }
1295
+ function loopbackHostRoles(bindHost) {
1296
+ const h = bindHost.trim().toLowerCase();
1297
+ if (h !== "127.0.0.1" && h !== "localhost") return null;
1298
+ const app2 = "localhost";
1299
+ const preview = loopbackCounterpart(app2);
1300
+ if (!preview) return null;
1301
+ return { app: app2, preview };
1302
+ }
1303
+ function requestAuthority(requestUrl, hostHeader) {
1304
+ const header = (hostHeader ?? "").trim();
1305
+ if (header !== "") return header;
1306
+ try {
1307
+ return new URL(requestUrl).host;
1308
+ } catch {
1309
+ return "";
1310
+ }
1311
+ }
1312
+ function resolvePreviewTarget(requestUrl, hostHeader, configuredOrigin, serverBind) {
1313
+ const requestHost = hostOnly(requestAuthority(requestUrl, hostHeader)).toLowerCase();
1314
+ if (configuredOrigin) {
1315
+ const url = new URL(configuredOrigin);
1316
+ if (url.hostname.toLowerCase() === requestHost) return null;
1317
+ return { origin: url.origin, host: url.hostname };
1318
+ }
1319
+ if (requestHost === "") return null;
1320
+ const counterpart = loopbackCounterpart(requestHost);
1321
+ if (!counterpart) return null;
1322
+ if (!loopbackHostRoles(serverBind.host)) return null;
1323
+ let protocol;
1324
+ try {
1325
+ protocol = new URL(requestUrl).protocol;
1326
+ } catch {
1327
+ protocol = "http:";
1328
+ }
1329
+ const suffix = protocol === "http:" && serverBind.port === 80 || protocol === "https:" && serverBind.port === 443 ? "" : `:${serverBind.port}`;
1330
+ return { origin: `${protocol}//${counterpart}${suffix}`, host: counterpart };
1331
+ }
1332
+
1333
+ // src/http/routes/me.ts
1236
1334
  function meRoutes(deps2) {
1237
1335
  const app2 = new Hono3();
1238
1336
  app2.get("/", (c) => {
1239
- return c.json({ user: toUserInfo(c.var.user) });
1337
+ const target = resolvePreviewTarget(
1338
+ c.req.url,
1339
+ c.req.header("host"),
1340
+ deps2.config.previewOrigin,
1341
+ deps2.config
1342
+ );
1343
+ return c.json({
1344
+ user: toUserInfo(c.var.user),
1345
+ previewIsolated: target !== null
1346
+ });
1240
1347
  });
1241
1348
  app2.put("/password", async (c) => {
1242
1349
  const body = await readJson(c);
@@ -2287,6 +2394,17 @@ function agentConfigRoutes(deps2) {
2287
2394
  activeSessionCount: deps2.manager.activeCountForAgent(projectId, agentId)
2288
2395
  });
2289
2396
  });
2397
+ app2.post("/reset", async (c) => {
2398
+ const projectId = requireValidId(c, "projectId");
2399
+ const agentId = requireValidId(c, "agentId");
2400
+ deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
2401
+ await deps2.agentConfigService.resetConfig(projectId, agentId);
2402
+ const view = await deps2.agentConfigService.getConfig(projectId, agentId);
2403
+ return c.json({
2404
+ ...view,
2405
+ activeSessionCount: deps2.manager.activeCountForAgent(projectId, agentId)
2406
+ });
2407
+ });
2290
2408
  return app2;
2291
2409
  }
2292
2410
 
@@ -2647,6 +2765,13 @@ var APPROVAL_MODES = [
2647
2765
  "read-only",
2648
2766
  "always-ask"
2649
2767
  ];
2768
+ var THINKING_LEVELS = ["none", "low", "medium", "high", "xhigh"];
2769
+ var SESSION_CATEGORIES = [
2770
+ "active",
2771
+ "subagent",
2772
+ "schedule",
2773
+ "archived"
2774
+ ];
2650
2775
  function parseTaskInput(body) {
2651
2776
  const input = body.input;
2652
2777
  if (!Array.isArray(input) || input.length === 0) {
@@ -2681,12 +2806,26 @@ function agentSessionsRoutes(deps2) {
2681
2806
  deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
2682
2807
  await deps2.agentConfigService.requireExists(projectId, agentId);
2683
2808
  const paging = optionalPagingQuery(c);
2684
- const sessions = await deps2.sessionService.listSessions(
2809
+ const rawCategory = c.req.query("category");
2810
+ if (rawCategory !== void 0 && !SESSION_CATEGORIES.includes(rawCategory)) {
2811
+ throw badRequest(`category must be one of ${SESSION_CATEGORIES.join(" / ")}.`);
2812
+ }
2813
+ const rawCounts = c.req.query("counts");
2814
+ if (rawCounts !== void 0 && rawCounts !== "1") throw badRequest("counts only accepts 1.");
2815
+ const { sessions, counts, workspaceCounts } = await deps2.sessionService.listSessions(
2685
2816
  projectId,
2686
2817
  agentId,
2687
- ...paging ? [paging] : []
2818
+ {
2819
+ ...paging ? { paging } : {},
2820
+ ...rawCategory !== void 0 ? { category: rawCategory } : {},
2821
+ ...rawCounts !== void 0 ? { withCounts: true } : {}
2822
+ }
2688
2823
  );
2689
- return c.json({ sessions });
2824
+ return c.json({
2825
+ sessions,
2826
+ ...counts ? { counts } : {},
2827
+ ...workspaceCounts ? { workspaceCounts } : {}
2828
+ });
2690
2829
  });
2691
2830
  app2.post("/", async (c) => {
2692
2831
  const projectId = requireValidId(c, "projectId");
@@ -2744,8 +2883,10 @@ function sessionsRoutes(deps2) {
2744
2883
  app2.get("/:sessionId", async (c) => {
2745
2884
  const row = resolveSession(c);
2746
2885
  const hasTrace = await deps2.sessionService.hasTrace(row);
2886
+ const info = await deps2.sessionService.toInfo(row, hasTrace);
2887
+ const tracePath = hasTrace ? await deps2.sessionService.latestTracePath(row) : void 0;
2747
2888
  return c.json({
2748
- session: await deps2.sessionService.toInfo(row, hasTrace)
2889
+ session: { ...info, ...tracePath !== void 0 ? { tracePath } : {} }
2749
2890
  });
2750
2891
  });
2751
2892
  app2.patch("/:sessionId", async (c) => {
@@ -2860,7 +3001,11 @@ function sessionsRoutes(deps2) {
2860
3001
  const row = resolveSession(c);
2861
3002
  const channel = deps2.channels.get(row.sessionId);
2862
3003
  const initialEvents = [
2863
- { type: "task_state", state: deps2.manager.statusOf(row.sessionId) },
3004
+ {
3005
+ type: "task_state",
3006
+ state: deps2.manager.statusOf(row.sessionId),
3007
+ queued: deps2.manager.pendingFollowUpCount(row.sessionId)
3008
+ },
2864
3009
  ...deps2.manager.pendingApprovals(row.sessionId).map((p) => ({
2865
3010
  type: "approval_request",
2866
3011
  toolCall: p.toolCall,
@@ -2871,9 +3016,23 @@ function sessionsRoutes(deps2) {
2871
3016
  });
2872
3017
  app2.post("/:sessionId/tasks", async (c) => {
2873
3018
  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);
3019
+ const body = await readJson(c);
3020
+ const input = parseTaskInput(body);
3021
+ const thinkingLevel = optionalEnum(body, "thinkingLevel", THINKING_LEVELS);
3022
+ const queueIfBusy = body.queueIfBusy === true;
3023
+ const { sessionId, queued } = await deps2.manager.startTask(row.sessionId, input, {
3024
+ ...thinkingLevel !== void 0 ? { thinkingLevel } : {},
3025
+ queueIfBusy
3026
+ });
3027
+ return c.json({ sessionId, queued }, 202);
3028
+ });
3029
+ app2.post("/:sessionId/steer", async (c) => {
3030
+ const row = resolveSession(c);
3031
+ const body = await readJson(c);
3032
+ const text = typeof body.text === "string" ? body.text.trim() : "";
3033
+ if (!text) throw badRequest("text must be a non-empty string.");
3034
+ deps2.manager.steer(row.sessionId, text);
3035
+ return c.body(null, 202);
2877
3036
  });
2878
3037
  app2.post("/:sessionId/approvals/:toolCallId", async (c) => {
2879
3038
  const row = resolveSession(c);
@@ -2927,6 +3086,31 @@ function sessionsRoutes(deps2) {
2927
3086
  }
2928
3087
  });
2929
3088
  });
3089
+ app2.get("/:sessionId/files/preview-redirect", async (c) => {
3090
+ const row = resolveSession(c);
3091
+ const rel = c.req.query("path") ?? "";
3092
+ const [exists] = await deps2.workspaceFiles.statExisting(row.workspace, [rel]);
3093
+ if (!exists) throw new HttpError(404, "file_not_found", "File does not exist.");
3094
+ const target = resolvePreviewTarget(
3095
+ c.req.url,
3096
+ c.req.header("host"),
3097
+ deps2.config.previewOrigin,
3098
+ deps2.config
3099
+ );
3100
+ if (!target) {
3101
+ return c.redirect(
3102
+ `/api/sessions/${row.sessionId}/files/content?path=${encodeURIComponent(rel)}&preview=1`,
3103
+ 302
3104
+ );
3105
+ }
3106
+ const token = deps2.previewTokens.sign({
3107
+ sessionId: row.sessionId,
3108
+ host: target.host,
3109
+ expiresAt: Date.now() + PREVIEW_TOKEN_TTL_MS
3110
+ });
3111
+ const encoded = rel.split("/").map(encodeURIComponent).join("/");
3112
+ return c.redirect(`${target.origin}/preview/${token}/${encoded}`, 302);
3113
+ });
2930
3114
  app2.post("/:sessionId/files/stat", async (c) => {
2931
3115
  const row = resolveSession(c);
2932
3116
  const body = await readJson(c);
@@ -3575,6 +3759,10 @@ var SessionManager = class {
3575
3759
  pendingApprovals(sessionId) {
3576
3760
  return this.entries.get(sessionId)?.approvals.list() ?? [];
3577
3761
  }
3762
+ /** Number of queued follow-up tasks (`queueIfBusy`) awaiting auto-start. */
3763
+ pendingFollowUpCount(sessionId) {
3764
+ return this.entries.get(sessionId)?.followUps.length ?? 0;
3765
+ }
3578
3766
  /** Number of Sessions for this Agent that are currently running / compacting. */
3579
3767
  activeCountForAgent(projectId, agentId) {
3580
3768
  let n = 0;
@@ -3597,6 +3785,7 @@ var SessionManager = class {
3597
3785
  abort: null,
3598
3786
  running: null,
3599
3787
  generation: this.generationOf(row.projectId, row.agentId),
3788
+ followUps: [],
3600
3789
  lastActivityMs: Date.now()
3601
3790
  });
3602
3791
  }
@@ -3618,38 +3807,84 @@ var SessionManager = class {
3618
3807
  * Start a Task: get-or-load → 409
3619
3808
  * mutual-exclusion check → publish the input messages first → drive run in the
3620
3809
  * background. Returns the current actual session_id (the new id after self-heal).
3810
+ * `opts.thinkingLevel` (optional, validated by the route) rides into this run's
3811
+ * `session.run` options — a per-turn parameter, applied to this Task only.
3812
+ * With `queueIfBusy`, a busy session (running/compacting) enqueues the input as a
3813
+ * follow-up instead of 409: it auto-starts as an ordinary next task once the session
3814
+ * returns to idle (`queued: true` in the result; see startQueuedFollowUp), keeping its
3815
+ * thinkingLevel for that auto-start.
3621
3816
  */
3622
- async startTask(sessionId, input) {
3817
+ async startTask(sessionId, input, opts) {
3623
3818
  return this.withLock(sessionId, async () => {
3624
3819
  this.assertOpen();
3625
3820
  this.assertAgentNotDeleting(sessionId);
3626
3821
  this.assertSessionNotDeleting(sessionId);
3627
3822
  const entry = await this.ensureEntry(sessionId);
3823
+ if (entry.status !== "idle" && opts?.queueIfBusy) {
3824
+ entry.followUps.push({
3825
+ input,
3826
+ ...opts.thinkingLevel !== void 0 ? { thinkingLevel: opts.thinkingLevel } : {}
3827
+ });
3828
+ entry.lastActivityMs = Date.now();
3829
+ this.publishState(entry, entry.status);
3830
+ return { sessionId: entry.sessionId, queued: true };
3831
+ }
3628
3832
  this.assertIdle(entry);
3629
- const channel = this.deps.channels.get(entry.sessionId);
3630
- const ac = new AbortController();
3631
- entry.status = "running";
3632
- entry.abort = ac;
3633
- entry.lastActivityMs = Date.now();
3634
- for (const msg of input) channel.publish(msg);
3635
- this.publishState(entry, "running");
3636
- const approve = makeApprove({
3637
- // Re-reads approval_mode from the DB on every decision (a PATCH takes effect immediately).
3638
- getMode: () => this.deps.sessions.findById(entry.sessionId)?.approvalMode ?? "always-ask",
3639
- toolPermission: (name) => entry.session.toolPermission(name),
3640
- registry: entry.approvals,
3641
- publishRequest: (pending) => this.publishEvent(entry, {
3642
- type: "approval_request",
3643
- toolCall: pending.toolCall,
3644
- ...pending.origin !== void 0 ? { origin: pending.origin } : {}
3645
- })
3646
- });
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 });
3650
- return { sessionId: entry.sessionId };
3833
+ this.launchTask(entry, input, opts?.thinkingLevel);
3834
+ return { sessionId: entry.sessionId, queued: false };
3651
3835
  });
3652
3836
  }
3837
+ /** 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
+ launchTask(entry, input, thinkingLevel) {
3839
+ const channel = this.deps.channels.get(entry.sessionId);
3840
+ const ac = new AbortController();
3841
+ entry.status = "running";
3842
+ entry.abort = ac;
3843
+ entry.lastActivityMs = Date.now();
3844
+ for (const msg of input) channel.publish(msg);
3845
+ this.publishState(entry, "running");
3846
+ const approve = makeApprove({
3847
+ // Re-reads approval_mode from the DB on every decision (a PATCH takes effect immediately).
3848
+ getMode: () => this.deps.sessions.findById(entry.sessionId)?.approvalMode ?? "always-ask",
3849
+ toolPermission: (name) => entry.session.toolPermission(name),
3850
+ registry: entry.approvals,
3851
+ publishRequest: (pending) => this.publishEvent(entry, {
3852
+ type: "approval_request",
3853
+ toolCall: pending.toolCall,
3854
+ ...pending.origin !== void 0 ? { origin: pending.origin } : {}
3855
+ })
3856
+ });
3857
+ const gen = entry.session.run(input, {
3858
+ approve,
3859
+ signal: ac.signal,
3860
+ ...thinkingLevel !== void 0 ? { thinkingLevel } : {}
3861
+ });
3862
+ const userExcerpt = input.filter(isPlainText("user")).map((m) => m.payload.text).join("\n");
3863
+ entry.running = this.drive(entry, gen, { userExcerpt });
3864
+ }
3865
+ /**
3866
+ * Auto-start of queued follow-ups: called from drive's finally whenever a Task or
3867
+ * compaction finishes (abort included — follow-ups are future tasks the user queued, so
3868
+ * an abort of the current run does not discard them). Re-validates everything under the
3869
+ * session lock (another task may have snuck in, the server may be shutting down, the
3870
+ * session may be getting deleted) and launches exactly one queued input — the next
3871
+ * finish picks up the one after, keeping order and one-at-a-time semantics.
3872
+ */
3873
+ async startQueuedFollowUp(sessionId) {
3874
+ try {
3875
+ await this.withLock(sessionId, async () => {
3876
+ if (this.closed || this.deletingSessions.has(sessionId)) return;
3877
+ const row = this.deps.sessions.findById(sessionId);
3878
+ if (row && this.deletingAgents.has(agentKey(row.projectId, row.agentId))) return;
3879
+ const entry = this.entries.get(sessionId);
3880
+ if (!entry || entry.status !== "idle" || entry.followUps.length === 0) return;
3881
+ const next = entry.followUps.shift();
3882
+ this.launchTask(entry, next.input, next.thinkingLevel);
3883
+ });
3884
+ } catch (err) {
3885
+ this.log(`[followup] auto-start failed: ${err instanceof Error ? err.message : String(err)}`);
3886
+ }
3887
+ }
3653
3888
  /** Manually compact the context: 409 if already running; compaction output also flows into the SSE channel. */
3654
3889
  async startCompact(sessionId) {
3655
3890
  return this.withLock(sessionId, async () => {
@@ -3676,6 +3911,24 @@ var SessionManager = class {
3676
3911
  if (!entry) return false;
3677
3912
  return entry.approvals.decide(toolCallId, decision);
3678
3913
  }
3914
+ /**
3915
+ * Mid-run steering: forward the text to the running Session (core delivers it between
3916
+ * turns as a standalone `[user_steering]` user message — no SSE event of its own; the
3917
+ * message arrives through the stream the drive loop already publishes). 409 when the
3918
+ * Session isn't running a Task (idle / compacting / not loaded) or the run finished in the
3919
+ * race window — the caller falls back to submitting a normal task.
3920
+ */
3921
+ steer(sessionId, text) {
3922
+ const entry = this.entries.get(sessionId);
3923
+ if (!entry || entry.status !== "running" || !entry.session.steer(text)) {
3924
+ throw new HttpError(
3925
+ 409,
3926
+ "not_running",
3927
+ "This Session has no Task in progress; send the message as a new task instead."
3928
+ );
3929
+ }
3930
+ entry.lastActivityMs = Date.now();
3931
+ }
3679
3932
  /**
3680
3933
  * Interrupt the current Task/compaction: pending approvals converge to deny first,
3681
3934
  * then the AbortSignal fires. Returns false if nothing is in progress (the route
@@ -3781,6 +4034,7 @@ var SessionManager = class {
3781
4034
  sweepIdle(now = Date.now(), idleMs = ENTRY_IDLE_MS) {
3782
4035
  for (const [key, entry] of this.entries) {
3783
4036
  if (entry.status !== "idle" || entry.approvals.size !== 0 || entry.running !== null) continue;
4037
+ if (entry.followUps.length > 0) continue;
3784
4038
  if (now - entry.lastActivityMs <= idleMs) continue;
3785
4039
  this.entries.delete(key);
3786
4040
  }
@@ -3872,6 +4126,7 @@ var SessionManager = class {
3872
4126
  abort: null,
3873
4127
  running: null,
3874
4128
  generation,
4129
+ followUps: [],
3875
4130
  lastActivityMs: Date.now()
3876
4131
  };
3877
4132
  this.entries.set(currentId, entry);
@@ -3976,6 +4231,7 @@ var SessionManager = class {
3976
4231
  fallbackText: titleSource.userExcerpt
3977
4232
  });
3978
4233
  }
4234
+ if (entry.followUps.length > 0) void this.startQueuedFollowUp(entry.sessionId);
3979
4235
  for (const child of children.values()) {
3980
4236
  if (!child.prompt.trim()) continue;
3981
4237
  this.deps.titles?.maybeGenerate(
@@ -4046,7 +4302,7 @@ var SessionManager = class {
4046
4302
  return child;
4047
4303
  }
4048
4304
  publishState(entry, state) {
4049
- this.publishEvent(entry, { type: "task_state", state });
4305
+ this.publishEvent(entry, { type: "task_state", state, queued: entry.followUps.length });
4050
4306
  }
4051
4307
  publishEvent(entry, event) {
4052
4308
  this.deps.channels.get(entry.sessionId).publish(event, "server_event");
@@ -4069,19 +4325,9 @@ var SessionManager = class {
4069
4325
  // src/runtime/scheduler.ts
4070
4326
  import { createHash as createHash3 } from "crypto";
4071
4327
  import { readdir } from "fs/promises";
4072
- import { agentsDir, userText as userText2 } from "@prismshadow/penguin-core";
4328
+ import { agentsDir, buildScheduledMessage, userText as userText2 } from "@prismshadow/penguin-core";
4073
4329
  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
- }
4330
+ var scheduledMessage = buildScheduledMessage;
4085
4331
  var Scheduler = class {
4086
4332
  constructor(deps2) {
4087
4333
  this.deps = deps2;
@@ -4500,7 +4746,7 @@ var UsageRecorder = class {
4500
4746
  };
4501
4747
 
4502
4748
  // src/services/ids.ts
4503
- import { randomBytes as randomBytes3 } from "crypto";
4749
+ import { randomBytes as randomBytes4 } from "crypto";
4504
4750
  var SEMANTIC_ID_PATTERN = /^[a-z][a-z0-9_]{1,63}$/;
4505
4751
  var USERNAME_PATTERN = /^[a-z][a-z0-9_]{1,31}$/;
4506
4752
  var PROJECT_SUFFIX_PATTERN = /^[a-z0-9_]+$/;
@@ -4585,6 +4831,7 @@ import {
4585
4831
  VAULT_VALUE_MAX_LENGTH,
4586
4832
  isValidVaultKey,
4587
4833
  loadAgentVault,
4834
+ resetSystemConfigToDefaults,
4588
4835
  saveAgentVault,
4589
4836
  systemConfigPath
4590
4837
  } from "@prismshadow/penguin-core";
@@ -5013,7 +5260,7 @@ function probeTps(outputTokens, windowMs) {
5013
5260
  }
5014
5261
 
5015
5262
  // src/services/agent-config-service.ts
5016
- var THINKING_LEVELS = ["none", "low", "medium", "high", "xhigh"];
5263
+ var THINKING_LEVELS2 = ["none", "low", "medium", "high", "xhigh"];
5017
5264
  var COMPACTION_MODES = ["summarize", "discard"];
5018
5265
  function asRecord(v) {
5019
5266
  return v !== null && typeof v === "object" && !Array.isArray(v) ? v : {};
@@ -5115,6 +5362,18 @@ var AgentConfigService = class {
5115
5362
  await fs10.writeFile(agentsMdPath(this.root, projectId, agentId), req.agentsMd, "utf8");
5116
5363
  }
5117
5364
  }
5365
+ /**
5366
+ * Overwrites system_config.yaml with the current code defaults (core's
5367
+ * resetSystemConfigToDefaults) — same semantics as updating an installed skill:
5368
+ * only the identity fields (name / description / version) survive; the system
5369
+ * prompt, max_turns, model/compaction settings and the tool list (incl. MCP
5370
+ * servers) become the current defaults. AGENTS.md, skills and the vault are
5371
+ * untouched.
5372
+ */
5373
+ async resetConfig(projectId, agentId) {
5374
+ await this.requireExists(projectId, agentId);
5375
+ await resetSystemConfigToDefaults(this.root, projectId, agentId);
5376
+ }
5118
5377
  async applyConfigUpdate(projectId, agentId, config2) {
5119
5378
  const cfg = config2;
5120
5379
  const yamlPath = systemConfigPath(this.root, projectId, agentId);
@@ -5143,7 +5402,7 @@ var AgentConfigService = class {
5143
5402
  );
5144
5403
  setIfProvided(
5145
5404
  ["model", "thinking_level"],
5146
- optionalEnum(model, "thinkingLevel", THINKING_LEVELS)
5405
+ optionalEnum(model, "thinkingLevel", THINKING_LEVELS2)
5147
5406
  );
5148
5407
  setIfProvided(
5149
5408
  ["model", "timeoutMs"],
@@ -5237,6 +5496,7 @@ function validateToolsBuiltin(value) {
5237
5496
  if (t.forModel !== void 0 && t.forModel !== "vision" && t.forModel !== "text-only") {
5238
5497
  throw badRequest(`toolsBuiltin[${i}].forModel must be one of vision / text-only.`);
5239
5498
  }
5499
+ optionalBoolean(t, "call_description", `toolsBuiltin[${i}].call_description`);
5240
5500
  optionalNumber(t, "timeoutMs", {
5241
5501
  integer: true,
5242
5502
  positiveOrMinusOne: true,
@@ -5615,7 +5875,7 @@ var BenchmarkService = class {
5615
5875
  };
5616
5876
 
5617
5877
  // src/services/snapshot-service.ts
5618
- import { randomBytes as randomBytes4 } from "crypto";
5878
+ import { randomBytes as randomBytes5 } from "crypto";
5619
5879
  import fs13 from "fs/promises";
5620
5880
  import path12 from "path";
5621
5881
  import * as tar from "tar";
@@ -5661,7 +5921,7 @@ var SnapshotService = class {
5661
5921
  } catch {
5662
5922
  }
5663
5923
  await fs13.mkdir(dir, { recursive: true });
5664
- const tmp = `${file}.tmp-${randomBytes4(4).toString("hex")}`;
5924
+ const tmp = `${file}.tmp-${randomBytes5(4).toString("hex")}`;
5665
5925
  await tar.create(
5666
5926
  {
5667
5927
  gzip: true,
@@ -5689,7 +5949,7 @@ var SnapshotService = class {
5689
5949
  async importArchive(projectId, agentId, archive, confirm) {
5690
5950
  const current = await this.currentVersion(projectId, agentId);
5691
5951
  const base = agentDir2(this.root, projectId, agentId);
5692
- const staging = path12.join(base, `.import-${randomBytes4(6).toString("hex")}`);
5952
+ const staging = path12.join(base, `.import-${randomBytes5(6).toString("hex")}`);
5693
5953
  await fs13.mkdir(staging, { recursive: true });
5694
5954
  try {
5695
5955
  const archiveFile = path12.join(staging, "archive.tar.gz");
@@ -6043,15 +6303,39 @@ var ProjectService = class {
6043
6303
 
6044
6304
  // src/services/session-service.ts
6045
6305
  import path13 from "path";
6046
- import { readdir as readdir2 } from "fs/promises";
6306
+ import { open, readdir as readdir2 } from "fs/promises";
6047
6307
  import {
6048
6308
  createAgent as createAgent2,
6309
+ findLatestTraceFile as findLatestTraceFile2,
6049
6310
  isSessionMeta as isSessionMeta4,
6311
+ parseTraceLines,
6050
6312
  readTraceTolerant,
6051
6313
  tracesDir as tracesDir2
6052
6314
  } from "@prismshadow/penguin-core";
6053
6315
  var TRACE_FILE_RE = /^(.+)_(\d{3})\.jsonl$/;
6054
6316
  var SESSION_ID_TS_RE = /^session-(\d{4})-(\d{2})-(\d{2})-(\d{2})-(\d{2})-(\d{2})-[0-9a-f]{8}$/;
6317
+ var TRACE_HEAD_BYTES = 256 * 1024;
6318
+ async function readTraceHead(filePath) {
6319
+ const fh = await open(filePath, "r");
6320
+ let text;
6321
+ let truncated;
6322
+ try {
6323
+ const { buffer, bytesRead } = await fh.read(
6324
+ Buffer.allocUnsafe(TRACE_HEAD_BYTES),
6325
+ 0,
6326
+ TRACE_HEAD_BYTES,
6327
+ 0
6328
+ );
6329
+ text = buffer.subarray(0, bytesRead).toString("utf8");
6330
+ truncated = bytesRead === TRACE_HEAD_BYTES;
6331
+ } finally {
6332
+ await fh.close();
6333
+ }
6334
+ if (!truncated) return parseTraceLines(text);
6335
+ const nl = text.lastIndexOf("\n");
6336
+ if (nl === -1) return readTraceTolerant(filePath);
6337
+ return parseTraceLines(text.slice(0, nl + 1));
6338
+ }
6055
6339
  function sessionIdCreatedAt(sessionId) {
6056
6340
  const m = SESSION_ID_TS_RE.exec(sessionId);
6057
6341
  if (!m) return null;
@@ -6067,10 +6351,11 @@ var SessionService = class {
6067
6351
  /**
6068
6352
  * DB row -> SessionInfo (run status and pending approval count come from session-manager).
6069
6353
  * 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).
6354
+ * this process) falls back to reading the Trace head once (see sourceOf). `traces` is the
6355
+ * list flow's one-walk discovery result; without it a miss locates the shard itself.
6071
6356
  */
6072
- async toInfo(row, hasTrace) {
6073
- const source = await this.sourceOf(row, hasTrace);
6357
+ async toInfo(row, hasTrace, traces) {
6358
+ const source = await this.sourceOf(row, hasTrace, traces);
6074
6359
  return {
6075
6360
  sessionId: row.sessionId,
6076
6361
  projectId: row.projectId,
@@ -6084,6 +6369,7 @@ var SessionService = class {
6084
6369
  createdAt: row.createdAt,
6085
6370
  status: this.deps.manager.statusOf(row.sessionId),
6086
6371
  pendingApprovalCount: this.deps.manager.pendingApprovalCount(row.sessionId),
6372
+ pendingFollowUpCount: this.deps.manager.pendingFollowUpCount(row.sessionId),
6087
6373
  hasTrace,
6088
6374
  archived: (row.archivedAt ?? null) !== null
6089
6375
  };
@@ -6095,11 +6381,13 @@ var SessionService = class {
6095
6381
  * session_meta is read once and cached. A Session with no Trace yet stays unknown and
6096
6382
  * is NOT cached negatively — its meta may appear with the first run.
6097
6383
  */
6098
- async sourceOf(row, hasTrace) {
6384
+ async sourceOf(row, hasTrace, traces) {
6099
6385
  const known = this.deps.sources.get(row.sessionId);
6100
6386
  if (known !== void 0) return known ?? void 0;
6101
6387
  if (!hasTrace) return void 0;
6102
- const meta = await this.readTraceMeta(row.projectId, row.agentId, row.sessionId);
6388
+ const location = traces?.get(row.sessionId) ?? (await this.discoverTraces(row.projectId, row.agentId)).get(row.sessionId);
6389
+ if (!location) return void 0;
6390
+ const meta = await this.readTraceMeta(location);
6103
6391
  if (!meta) return void 0;
6104
6392
  const source = asSessionSource(meta.payload.source) ?? null;
6105
6393
  this.deps.sources.set(row.sessionId, source);
@@ -6107,8 +6395,17 @@ var SessionService = class {
6107
6395
  }
6108
6396
  /** Whether this Session already has a Trace record (a Task has been run). */
6109
6397
  async hasTrace(row) {
6110
- const ids = await this.discoverTraceSessionIds(row.projectId, row.agentId);
6111
- return ids.has(row.sessionId);
6398
+ return (await this.discoverTraces(row.projectId, row.agentId)).has(row.sessionId);
6399
+ }
6400
+ /**
6401
+ * The list category of a row: archived wins (an explicit user action), then the
6402
+ * origin's bucket, and no/unknown source is `active` — the same precedence the
6403
+ * sidebar's partition applies to loaded rows, so server filtering and client
6404
+ * rendering can never disagree.
6405
+ */
6406
+ async categoryOf(row, hasTrace, traces) {
6407
+ if ((row.archivedAt ?? null) !== null) return "archived";
6408
+ return await this.sourceOf(row, hasTrace, traces) ?? "active";
6112
6409
  }
6113
6410
  /**
6114
6411
  * List: DB ∪ Trace directory discovery, sorted by createdAt descending. Optional
@@ -6116,22 +6413,58 @@ var SessionService = class {
6116
6413
  * more"); slicing happens before toInfo, so per-request source derivation (lazy
6117
6414
  * Trace-head reads) stays bounded by the page size. Discovery/adoption still scans
6118
6415
  * the whole directory — the union and global ordering need every id.
6416
+ *
6417
+ * `category` filters to one sidebar bucket **before** paging, so offset/limit page
6418
+ * within the category. Filtering needs each walked row's category (a possible
6419
+ * Trace-head read per row, cached in the sources registry); without `withCounts`
6420
+ * the walk stops as soon as the requested page is complete. `withCounts` classifies
6421
+ * every row and returns per-category totals over the whole list — plus the same
6422
+ * totals broken down by Workspace path — so the sidebar can label the collapsed
6423
+ * folders (and a workspace group can know its own share) without loading them.
6119
6424
  */
6120
- async listSessions(projectId, agentId, paging) {
6121
- const traceIds = await this.discoverTraceSessionIds(projectId, agentId);
6425
+ async listSessions(projectId, agentId, opts = {}) {
6426
+ const { paging, category, withCounts } = opts;
6427
+ const traces = await this.discoverTraces(projectId, agentId);
6122
6428
  const rows = new Map(
6123
6429
  this.deps.sessions.listByAgent(projectId, agentId).map((r) => [r.sessionId, r])
6124
6430
  );
6125
- for (const sessionId of traceIds) {
6431
+ for (const [sessionId, location] of traces) {
6126
6432
  if (rows.has(sessionId)) continue;
6127
- const discovered = await this.adoptTraceSession(projectId, agentId, sessionId);
6433
+ const discovered = await this.adoptTraceSession(projectId, agentId, sessionId, location);
6128
6434
  if (discovered) rows.set(sessionId, discovered);
6129
6435
  }
6130
6436
  const sorted = [...rows.values()].sort(
6131
6437
  (a, b) => b.createdAt.localeCompare(a.createdAt) || b.sessionId.localeCompare(a.sessionId)
6132
6438
  );
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))));
6439
+ const toPage = (page) => Promise.all(page.map((row) => this.toInfo(row, traces.has(row.sessionId), traces)));
6440
+ if (category === void 0 && !withCounts) {
6441
+ return {
6442
+ sessions: await toPage(
6443
+ paging ? sorted.slice(paging.offset, paging.offset + paging.limit) : sorted
6444
+ )
6445
+ };
6446
+ }
6447
+ const want = paging ? paging.offset + paging.limit : Infinity;
6448
+ const counts = { active: 0, subagent: 0, schedule: 0, archived: 0 };
6449
+ const workspaceCounts = {};
6450
+ const matched = [];
6451
+ for (const row of sorted) {
6452
+ if (!withCounts && matched.length >= want) break;
6453
+ const cat = await this.categoryOf(row, traces.has(row.sessionId), traces);
6454
+ counts[cat] += 1;
6455
+ if (withCounts) {
6456
+ const ws = workspaceCounts[row.workspace] ??= {
6457
+ active: 0,
6458
+ subagent: 0,
6459
+ schedule: 0,
6460
+ archived: 0
6461
+ };
6462
+ ws[cat] += 1;
6463
+ }
6464
+ if ((category === void 0 || cat === category) && matched.length < want) matched.push(row);
6465
+ }
6466
+ const sessions = await toPage(paging ? matched.slice(paging.offset, want) : matched);
6467
+ return withCounts ? { sessions, counts, workspaceCounts } : { sessions };
6135
6468
  }
6136
6469
  /**
6137
6470
  * Session stats (Agents list card): total count = size of the union of DB index
@@ -6243,48 +6576,59 @@ var SessionService = class {
6243
6576
  this.deps.manager.adopt(row, session);
6244
6577
  return this.toInfo(row, false);
6245
6578
  }
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;
6579
+ /**
6580
+ * Absolute path of a Session's **latest** Trace file (the current context shard);
6581
+ * undefined when no Trace exists. Costs a directory walk, so only the single-session
6582
+ * GET includes it in the DTO (see SessionInfo.tracePath) — the web's `/model` switch
6583
+ * hands it to the new session's `[model_switch_from]` block so the model can read the
6584
+ * source history itself when it needs it.
6585
+ */
6586
+ async latestTracePath(row) {
6587
+ const located = await findLatestTraceFile2(
6588
+ tracesDir2(this.deps.root, row.projectId, row.agentId),
6589
+ row.sessionId
6590
+ );
6591
+ return located?.path;
6257
6592
  }
6258
6593
  /**
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.
6594
+ * One walk over the Trace directory: session_id → its **earliest** shard (the shard
6595
+ * whose head carries the original session_meta). Discovery (which Sessions have
6596
+ * records) and the meta-read location come out of a single pass, so classifying every
6597
+ * row (`counts=1`) costs one directory walk total instead of one per Session.
6262
6598
  */
6263
- async readTraceMeta(projectId, agentId, sessionId) {
6599
+ async discoverTraces(projectId, agentId) {
6264
6600
  const dir = tracesDir2(this.deps.root, projectId, agentId);
6265
- let earliest = null;
6601
+ const out = /* @__PURE__ */ new Map();
6266
6602
  for (const dateDir of await listDirsSafe(dir)) {
6267
6603
  for (const file of await listFilesSafe(path13.join(dir, dateDir))) {
6268
6604
  const match = TRACE_FILE_RE.exec(file);
6269
- if (!match || match[1] !== sessionId) continue;
6605
+ if (!match) continue;
6606
+ const sessionId = match[1];
6270
6607
  const index = Number(match[2]);
6271
- if (!earliest || index < earliest.index) {
6272
- earliest = { path: path13.join(dir, dateDir, file), index };
6608
+ const cur = out.get(sessionId);
6609
+ if (!cur || index < cur.index) {
6610
+ out.set(sessionId, { path: path13.join(dir, dateDir, file), index });
6273
6611
  }
6274
6612
  }
6275
6613
  }
6276
- if (!earliest) return null;
6614
+ return out;
6615
+ }
6616
+ /**
6617
+ * session_meta from a located Trace shard head; null when unreadable or it has no
6618
+ * meta. Shared by adoption backfill and lazy `source` resolution.
6619
+ */
6620
+ async readTraceMeta(location) {
6277
6621
  let messages;
6278
6622
  try {
6279
- messages = await readTraceTolerant(earliest.path);
6623
+ messages = await readTraceHead(location.path);
6280
6624
  } catch {
6281
6625
  return null;
6282
6626
  }
6283
6627
  return messages.find(isSessionMeta4) ?? null;
6284
6628
  }
6285
6629
  /** 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);
6630
+ async adoptTraceSession(projectId, agentId, sessionId, location) {
6631
+ const meta = await this.readTraceMeta(location);
6288
6632
  if (!meta) return null;
6289
6633
  if (typeof meta.payload.provider !== "string") return null;
6290
6634
  this.deps.sources.set(sessionId, asSessionSource(meta.payload.source) ?? null);
@@ -6328,7 +6672,12 @@ async function listFilesSafe(dir) {
6328
6672
  // src/services/trace-service.ts
6329
6673
  import fs15 from "fs/promises";
6330
6674
  import path14 from "path";
6331
- import { agentsDir as agentsDir3, readTraceTolerant as readTraceTolerant2, tracesDir as tracesDir3 } from "@prismshadow/penguin-core";
6675
+ import {
6676
+ agentsDir as agentsDir3,
6677
+ parseUserSteeringText,
6678
+ readTraceTolerant as readTraceTolerant2,
6679
+ tracesDir as tracesDir3
6680
+ } from "@prismshadow/penguin-core";
6332
6681
  var TRACE_FILE_RE2 = /^(.+)_(\d{3})\.jsonl$/;
6333
6682
  var MAX_SUBAGENT_DEPTH = 4;
6334
6683
  function subagentPointer(msg) {
@@ -6519,7 +6868,9 @@ var TraceService = class {
6519
6868
  const msg = messages[mi];
6520
6869
  const p = msg.payload;
6521
6870
  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");
6871
+ const isSteeringText = !hasOrigin && msg.type === "model_msg" && p.type === "text" && p.role === "user" && typeof p.text === "string" && parseUserSteeringText(p.text) !== null;
6872
+ if (isSteeringText) continuation = true;
6873
+ const startsUserTurn = !hasOrigin && !compactionActive && !isSteeringText && msg.type === "model_msg" && (p.type === "text" && p.role === "user" || p.type === "image_url");
6523
6874
  const startsCompactionTurn = !hasOrigin && msg.type === "event_msg" && p.type === "compaction_begin";
6524
6875
  if (startsUserTurn || startsCompactionTurn) {
6525
6876
  if (pendingFrom === null) pendingFrom = mi;
@@ -6918,6 +7269,53 @@ var UsageService = class {
6918
7269
  }
6919
7270
  };
6920
7271
 
7272
+ // src/http/routes/preview.ts
7273
+ import { Hono as Hono19 } from "hono";
7274
+ var DIRECTORY_INDEX = "index.html";
7275
+ function previewRoutes(deps2) {
7276
+ const app2 = new Hono19();
7277
+ app2.get("/:token/*", async (c) => {
7278
+ const token = c.req.param("token");
7279
+ const payload = token ? deps2.previewTokens.verify(token) : null;
7280
+ if (!payload) return c.text("Not found", 404);
7281
+ const authority = requestAuthority(c.req.url, c.req.header("host"));
7282
+ if (hostOnly(authority).toLowerCase() !== payload.host.toLowerCase()) {
7283
+ return c.text("Not found", 404);
7284
+ }
7285
+ const row = deps2.sessionsRepo.findById(payload.sessionId);
7286
+ if (!row) return c.text("Not found", 404);
7287
+ const prefix = `/preview/${token}/`;
7288
+ const raw = c.req.path.startsWith(prefix) ? c.req.path.slice(prefix.length) : "";
7289
+ let rel;
7290
+ try {
7291
+ rel = decodeURIComponent(raw);
7292
+ } catch {
7293
+ return c.text("Not found", 404);
7294
+ }
7295
+ if (rel === "" || rel.endsWith("/")) rel += DIRECTORY_INDEX;
7296
+ let file;
7297
+ try {
7298
+ file = await deps2.workspaceFiles.read(row.workspace, rel);
7299
+ } catch {
7300
+ return c.text("Not found", 404);
7301
+ }
7302
+ return new Response(new Uint8Array(file.data), {
7303
+ status: 200,
7304
+ headers: {
7305
+ "Content-Type": file.contentType,
7306
+ "X-Content-Type-Options": "nosniff",
7307
+ // Without this, third-party requests made by the page leak the token-bearing
7308
+ // URL through Referer — a risk that only exists because this origin exists to
7309
+ // let third-party embeds run.
7310
+ "Referrer-Policy": "no-referrer",
7311
+ // Previews are per-token and short-lived; never let a shared cache keep them.
7312
+ "Cache-Control": "no-store"
7313
+ }
7314
+ });
7315
+ });
7316
+ return app2;
7317
+ }
7318
+
6921
7319
  // src/app.ts
6922
7320
  var MAX_BODY_BYTES = 20 * 1024 * 1024;
6923
7321
  function buildAppDeps(config2, overrides = {}) {
@@ -6938,6 +7336,7 @@ function buildAppDeps(config2, overrides = {}) {
6938
7336
  const agentService = new AgentService(config2.root, agentsRepo, agentConfigService);
6939
7337
  const traceService = new TraceService(config2.root);
6940
7338
  const workspaceFiles = new WorkspaceFilesService();
7339
+ const previewTokens = createPreviewTokenSigner();
6941
7340
  const benchmarks = new BenchmarkService(config2.root);
6942
7341
  const snapshots = new SnapshotService(config2.root);
6943
7342
  const usageService = new UsageService(
@@ -7028,6 +7427,7 @@ function buildAppDeps(config2, overrides = {}) {
7028
7427
  traceService,
7029
7428
  usageService,
7030
7429
  workspaceFiles,
7430
+ previewTokens,
7031
7431
  benchmarks,
7032
7432
  snapshots,
7033
7433
  schedulesRepo,
@@ -7040,7 +7440,7 @@ function buildAppDeps(config2, overrides = {}) {
7040
7440
  };
7041
7441
  }
7042
7442
  function createApp(deps2) {
7043
- const app2 = new Hono19();
7443
+ const app2 = new Hono20();
7044
7444
  app2.onError((err, c) => {
7045
7445
  const projectId = attributedProjectId(c, deps2);
7046
7446
  deps2.errors.record({
@@ -7057,6 +7457,21 @@ function createApp(deps2) {
7057
7457
  const ms = Math.round(performance.now() - start);
7058
7458
  deps2.log(`${c.req.method} ${c.req.path} ${c.res.status} ${ms}ms`);
7059
7459
  });
7460
+ const previewRoles = deps2.config.previewOrigin ? null : loopbackHostRoles(deps2.config.host);
7461
+ if (previewRoles) {
7462
+ app2.use("*", async (c, next) => {
7463
+ const host = hostOnly(requestAuthority(c.req.url, c.req.header("host"))).toLowerCase();
7464
+ if (host === previewRoles.preview && !c.req.path.startsWith("/preview/")) {
7465
+ if (c.req.path.startsWith("/api/")) {
7466
+ throw new HttpError(401, "unauthorized", "The API is not served on the preview host.");
7467
+ }
7468
+ const url = new URL(c.req.url);
7469
+ url.hostname = previewRoles.app;
7470
+ return c.redirect(url.toString(), 302);
7471
+ }
7472
+ await next();
7473
+ });
7474
+ }
7060
7475
  app2.use("/api/*", async (c, next) => {
7061
7476
  const contentLength = Number(c.req.header("content-length") ?? 0);
7062
7477
  if (contentLength > MAX_BODY_BYTES) {
@@ -7087,6 +7502,7 @@ function createApp(deps2) {
7087
7502
  app2.route("/api/projects/:projectId/agents/:agentId/sessions", agentSessionsRoutes(deps2));
7088
7503
  app2.route("/api/projects/:projectId/usage", usageRoutes(deps2));
7089
7504
  app2.route("/api/sessions", sessionsRoutes(deps2));
7505
+ app2.route("/preview", previewRoutes(deps2));
7090
7506
  if (fs16.existsSync(deps2.config.webDist)) {
7091
7507
  registerStaticRoutes(app2, deps2.config.webDist);
7092
7508
  }
@@ -7160,6 +7576,19 @@ function defaultWebDist() {
7160
7576
  if (fs17.existsSync(bundled)) return bundled;
7161
7577
  return path16.resolve(here, "..", "..", "web", "dist");
7162
7578
  }
7579
+ function normalizePreviewOrigin(raw) {
7580
+ if (!raw || raw.trim() === "") return null;
7581
+ let url;
7582
+ try {
7583
+ url = new URL(raw.trim());
7584
+ } catch {
7585
+ throw new Error(`Invalid PENGUIN_PREVIEW_ORIGIN=${raw} (expected an absolute origin)`);
7586
+ }
7587
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
7588
+ throw new Error(`Invalid PENGUIN_PREVIEW_ORIGIN=${raw} (only http/https are supported)`);
7589
+ }
7590
+ return url.origin;
7591
+ }
7163
7592
  function resolveServerConfig(env = process.env) {
7164
7593
  const root = env.PENGUIN_HOME ?? resolveRoot();
7165
7594
  const port = Number(env.PORT || DEFAULT_SERVER_PORT);
@@ -7172,6 +7601,7 @@ function resolveServerConfig(env = process.env) {
7172
7601
  port,
7173
7602
  dbPath: env.PENGUIN_WEB_DB ?? path16.join(root, "web.db"),
7174
7603
  webDist: env.PENGUIN_WEB_DIST ?? defaultWebDist(),
7604
+ previewOrigin: normalizePreviewOrigin(env.PENGUIN_PREVIEW_ORIGIN),
7175
7605
  authSessionTtlMs: 7 * DAY_MS,
7176
7606
  authSessionRenewMs: 6 * DAY_MS
7177
7607
  };
@@ -7184,11 +7614,18 @@ var deps = buildAppDeps(config);
7184
7614
  var app = createApp(deps);
7185
7615
  await deps.authService.seedAdmin();
7186
7616
  await deps.scheduler.start();
7617
+ var appHost = loopbackHostRoles(config.host)?.app ?? config.host;
7187
7618
  var server = serve({ fetch: app.fetch, hostname: config.host, port: config.port }, (info) => {
7188
- console.log(`penguin-server started: http://${config.host}:${info.port}`);
7619
+ console.log(`penguin-server started: http://${appHost}:${info.port}`);
7189
7620
  console.log(`Data root: ${config.root}`);
7190
7621
  console.log(`SQLite: ${config.dbPath}`);
7191
7622
  });
7623
+ var ipv6Loopback = config.host === "127.0.0.1" || config.host === "localhost" ? serve({ fetch: app.fetch, hostname: "::1", port: config.port }) : null;
7624
+ ipv6Loopback?.on("error", (err) => {
7625
+ console.warn(
7626
+ `[server] IPv6 loopback listener unavailable (${err.code ?? err.message}); previews via localhost may not resolve.`
7627
+ );
7628
+ });
7192
7629
  var shuttingDown = false;
7193
7630
  async function shutdown(signal, exitCode = 0) {
7194
7631
  if (shuttingDown) return;
@@ -7197,6 +7634,7 @@ async function shutdown(signal, exitCode = 0) {
7197
7634
  deps.scheduler.stop();
7198
7635
  await deps.manager.shutdown(5e3);
7199
7636
  deps.channels.dispose();
7637
+ ipv6Loopback?.close();
7200
7638
  server.close(() => {
7201
7639
  deps.db.close();
7202
7640
  process.exit(exitCode);