@prismshadow/penguin-server 0.1.0 → 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";
@@ -54,9 +54,8 @@ CREATE TABLE IF NOT EXISTS sessions (
54
54
  approval_mode TEXT NOT NULL DEFAULT 'allow-all', -- allow-all|deny-all|read-only|always-ask
55
55
  title TEXT, -- auto-generated by the model after the first exchange; NULL=not yet (frontend shows "New chat")
56
56
  archived_at TEXT, -- archive time; NULL=not archived (shown by default; archived ones move under "Archived")
57
- source TEXT, -- session origin: NULL=user-created | schedule (scheduled task) | subagent (child session)
58
57
  created_at TEXT NOT NULL
59
- );
58
+ ); -- session origin is NOT stored: session_meta in the Trace is the single source of truth (see runtime/session-sources.ts)
60
59
  CREATE TABLE IF NOT EXISTS usage_records (
61
60
  id INTEGER PRIMARY KEY AUTOINCREMENT,
62
61
  ts TEXT NOT NULL,
@@ -525,7 +524,6 @@ function mapRow3(r) {
525
524
  approvalMode: r.approval_mode,
526
525
  title: r.title ?? null,
527
526
  archivedAt: r.archived_at ?? null,
528
- source: r.source ?? null,
529
527
  createdAt: r.created_at
530
528
  };
531
529
  }
@@ -536,8 +534,8 @@ var SessionsRepo = class {
536
534
  db;
537
535
  insert(row) {
538
536
  this.db.prepare(
539
- `INSERT INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, source, created_at)
540
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
537
+ `INSERT INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, created_at)
538
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
541
539
  ).run(
542
540
  row.sessionId,
543
541
  row.projectId,
@@ -547,15 +545,14 @@ var SessionsRepo = class {
547
545
  row.workspace,
548
546
  row.approvalMode,
549
547
  row.title,
550
- row.source ?? null,
551
548
  row.createdAt
552
549
  );
553
550
  }
554
551
  /** Idempotent insert: used when Trace directory discovery backfills a row (concurrent listing discovering the same Session no longer triggers a UNIQUE violation). */
555
552
  insertOrIgnore(row) {
556
553
  this.db.prepare(
557
- `INSERT OR IGNORE INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, source, created_at)
558
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
554
+ `INSERT OR IGNORE INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, created_at)
555
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
559
556
  ).run(
560
557
  row.sessionId,
561
558
  row.projectId,
@@ -565,7 +562,6 @@ var SessionsRepo = class {
565
562
  row.workspace,
566
563
  row.approvalMode,
567
564
  row.title,
568
- row.source ?? null,
569
565
  row.createdAt
570
566
  );
571
567
  }
@@ -1073,8 +1069,10 @@ function requireValidId(c, name) {
1073
1069
  return v;
1074
1070
  }
1075
1071
  function positiveIntParam(c, name) {
1076
- const v = Number.parseInt(pathParam(c, name), 10);
1077
- if (!Number.isInteger(v) || v < 1) throw badRequest(`${name} must be a positive integer.`);
1072
+ const raw = pathParam(c, name);
1073
+ if (!/^\d+$/.test(raw)) throw badRequest(`${name} must be a positive integer.`);
1074
+ const v = Number.parseInt(raw, 10);
1075
+ if (!Number.isSafeInteger(v) || v < 1) throw badRequest(`${name} must be a positive integer.`);
1078
1076
  return v;
1079
1077
  }
1080
1078
  function paginationQuery(c) {
@@ -1087,6 +1085,21 @@ function paginationQuery(c) {
1087
1085
  }
1088
1086
  return { offset, limit };
1089
1087
  }
1088
+ function optionalPagingQuery(c) {
1089
+ const rawLimit = c.req.query("limit");
1090
+ const rawOffset = c.req.query("offset");
1091
+ if (rawLimit === void 0 && rawOffset === void 0) return null;
1092
+ if (rawLimit === void 0) throw badRequest("offset requires limit.");
1093
+ const limit = Number.parseInt(rawLimit, 10);
1094
+ if (!Number.isInteger(limit) || limit < 1 || limit > 1e3) {
1095
+ throw badRequest("limit must be an integer between 1 and 1000.");
1096
+ }
1097
+ const offset = Number.parseInt(rawOffset ?? "0", 10);
1098
+ if (!Number.isInteger(offset) || offset < 0) {
1099
+ throw badRequest("offset must be a non-negative integer.");
1100
+ }
1101
+ return { offset, limit };
1102
+ }
1090
1103
  async function readJson(c) {
1091
1104
  let body;
1092
1105
  try {
@@ -1141,12 +1154,23 @@ function optionalNumber(obj, key, rule = {}) {
1141
1154
  if (rule.nonNegative && v < 0) throw badRequest(`${label} must not be negative.`);
1142
1155
  return v;
1143
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
+ }
1144
1163
  function optionalDateParam(value, label) {
1145
1164
  if (value === void 0 || value === "") return void 0;
1146
- if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
1147
- throw badRequest(`${label} must be in YYYY-MM-DD format.`);
1165
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
1166
+ if (m) {
1167
+ const [, y, mo, d] = m;
1168
+ const dt = /* @__PURE__ */ new Date(`${value}T00:00:00Z`);
1169
+ if (!Number.isNaN(dt.getTime()) && dt.getUTCFullYear() === Number(y) && dt.getUTCMonth() + 1 === Number(mo) && dt.getUTCDate() === Number(d)) {
1170
+ return value;
1171
+ }
1148
1172
  }
1149
- return value;
1173
+ throw badRequest(`${label} must be a valid date in YYYY-MM-DD format.`);
1150
1174
  }
1151
1175
 
1152
1176
  // src/http/routes/admin.ts
@@ -1215,10 +1239,111 @@ function authRoutes(deps2) {
1215
1239
 
1216
1240
  // src/http/routes/me.ts
1217
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
1218
1334
  function meRoutes(deps2) {
1219
1335
  const app2 = new Hono3();
1220
1336
  app2.get("/", (c) => {
1221
- 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
+ });
1222
1347
  });
1223
1348
  app2.put("/password", async (c) => {
1224
1349
  const body = await readJson(c);
@@ -1445,6 +1570,12 @@ function parseModelsUpdate(body) {
1445
1570
  }
1446
1571
  entry.vision = m.vision;
1447
1572
  }
1573
+ if (m.maxTokens !== void 0) {
1574
+ if (typeof m.maxTokens !== "number" || !Number.isInteger(m.maxTokens) || m.maxTokens <= 0) {
1575
+ throw badRequest(`models[${i}].maxTokens must be a positive integer.`);
1576
+ }
1577
+ entry.maxTokens = m.maxTokens;
1578
+ }
1448
1579
  if (m.pricing !== void 0) {
1449
1580
  const p = m.pricing;
1450
1581
  if (p === null || typeof p !== "object" || Array.isArray(p)) {
@@ -2263,6 +2394,17 @@ function agentConfigRoutes(deps2) {
2263
2394
  activeSessionCount: deps2.manager.activeCountForAgent(projectId, agentId)
2264
2395
  });
2265
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
+ });
2266
2408
  return app2;
2267
2409
  }
2268
2410
 
@@ -2623,6 +2765,13 @@ var APPROVAL_MODES = [
2623
2765
  "read-only",
2624
2766
  "always-ask"
2625
2767
  ];
2768
+ var THINKING_LEVELS = ["none", "low", "medium", "high", "xhigh"];
2769
+ var SESSION_CATEGORIES = [
2770
+ "active",
2771
+ "subagent",
2772
+ "schedule",
2773
+ "archived"
2774
+ ];
2626
2775
  function parseTaskInput(body) {
2627
2776
  const input = body.input;
2628
2777
  if (!Array.isArray(input) || input.length === 0) {
@@ -2656,8 +2805,27 @@ function agentSessionsRoutes(deps2) {
2656
2805
  const agentId = requireValidId(c, "agentId");
2657
2806
  deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
2658
2807
  await deps2.agentConfigService.requireExists(projectId, agentId);
2659
- const sessions = await deps2.sessionService.listSessions(projectId, agentId);
2660
- return c.json({ sessions });
2808
+ const paging = optionalPagingQuery(c);
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(
2816
+ projectId,
2817
+ agentId,
2818
+ {
2819
+ ...paging ? { paging } : {},
2820
+ ...rawCategory !== void 0 ? { category: rawCategory } : {},
2821
+ ...rawCounts !== void 0 ? { withCounts: true } : {}
2822
+ }
2823
+ );
2824
+ return c.json({
2825
+ sessions,
2826
+ ...counts ? { counts } : {},
2827
+ ...workspaceCounts ? { workspaceCounts } : {}
2828
+ });
2661
2829
  });
2662
2830
  app2.post("/", async (c) => {
2663
2831
  const projectId = requireValidId(c, "projectId");
@@ -2715,7 +2883,11 @@ function sessionsRoutes(deps2) {
2715
2883
  app2.get("/:sessionId", async (c) => {
2716
2884
  const row = resolveSession(c);
2717
2885
  const hasTrace = await deps2.sessionService.hasTrace(row);
2718
- return c.json({ session: deps2.sessionService.toInfo(row, hasTrace) });
2886
+ const info = await deps2.sessionService.toInfo(row, hasTrace);
2887
+ const tracePath = hasTrace ? await deps2.sessionService.latestTracePath(row) : void 0;
2888
+ return c.json({
2889
+ session: { ...info, ...tracePath !== void 0 ? { tracePath } : {} }
2890
+ });
2719
2891
  });
2720
2892
  app2.patch("/:sessionId", async (c) => {
2721
2893
  const row = resolveSession(c);
@@ -2761,7 +2933,7 @@ function sessionsRoutes(deps2) {
2761
2933
  }
2762
2934
  const hasTrace = await deps2.sessionService.hasTrace(updated);
2763
2935
  return c.json({
2764
- session: deps2.sessionService.toInfo(updated, hasTrace)
2936
+ session: await deps2.sessionService.toInfo(updated, hasTrace)
2765
2937
  });
2766
2938
  });
2767
2939
  app2.delete("/:sessionId", async (c) => {
@@ -2780,6 +2952,7 @@ function sessionsRoutes(deps2) {
2780
2952
  { recursive: true, force: true }
2781
2953
  );
2782
2954
  deps2.sessionsRepo.deleteById(row.sessionId);
2955
+ deps2.sessionSources.delete(row.sessionId);
2783
2956
  } finally {
2784
2957
  deps2.manager.endSessionDeletion(row.sessionId);
2785
2958
  }
@@ -2828,7 +3001,11 @@ function sessionsRoutes(deps2) {
2828
3001
  const row = resolveSession(c);
2829
3002
  const channel = deps2.channels.get(row.sessionId);
2830
3003
  const initialEvents = [
2831
- { 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
+ },
2832
3009
  ...deps2.manager.pendingApprovals(row.sessionId).map((p) => ({
2833
3010
  type: "approval_request",
2834
3011
  toolCall: p.toolCall,
@@ -2839,9 +3016,23 @@ function sessionsRoutes(deps2) {
2839
3016
  });
2840
3017
  app2.post("/:sessionId/tasks", async (c) => {
2841
3018
  const row = resolveSession(c);
2842
- const input = parseTaskInput(await readJson(c));
2843
- const { sessionId } = await deps2.manager.startTask(row.sessionId, input);
2844
- 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);
2845
3036
  });
2846
3037
  app2.post("/:sessionId/approvals/:toolCallId", async (c) => {
2847
3038
  const row = resolveSession(c);
@@ -2895,6 +3086,31 @@ function sessionsRoutes(deps2) {
2895
3086
  }
2896
3087
  });
2897
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
+ });
2898
3114
  app2.post("/:sessionId/files/stat", async (c) => {
2899
3115
  const row = resolveSession(c);
2900
3116
  const body = await readJson(c);
@@ -3218,6 +3434,26 @@ function makeApprove(args) {
3218
3434
  };
3219
3435
  }
3220
3436
 
3437
+ // src/runtime/session-sources.ts
3438
+ function asSessionSource(v) {
3439
+ return v === "schedule" || v === "subagent" ? v : void 0;
3440
+ }
3441
+ var SessionSources = class {
3442
+ map = /* @__PURE__ */ new Map();
3443
+ /** Records a Session's origin as read from session_meta (`null` = meta seen, user-created). */
3444
+ set(sessionId, source) {
3445
+ this.map.set(sessionId, source);
3446
+ }
3447
+ /** Known origin, `null` for a known user-created Session, `undefined` when this process has not seen the meta. */
3448
+ get(sessionId) {
3449
+ return this.map.get(sessionId);
3450
+ }
3451
+ /** Drops a deleted Session's entry (bulk Agent/Project deletion may leave stale entries; they are never matched again). */
3452
+ delete(sessionId) {
3453
+ this.map.delete(sessionId);
3454
+ }
3455
+ };
3456
+
3221
3457
  // src/runtime/stream-error-watcher.ts
3222
3458
  import { isEventMessage, isModelMessage, isSessionMeta } from "@prismshadow/penguin-core";
3223
3459
  import path7 from "path";
@@ -3396,7 +3632,7 @@ function compactUnavailable(why) {
3396
3632
  };
3397
3633
  return new HttpError(409, "nothing_to_compact", messages[why]);
3398
3634
  }
3399
- function createCoreSessionLoader(root) {
3635
+ function createCoreSessionLoader(root, sources) {
3400
3636
  return {
3401
3637
  async load(row) {
3402
3638
  const agent = await createAgent({
@@ -3426,11 +3662,14 @@ function createCoreSessionLoader(root) {
3426
3662
  `This Session's Workspace no longer exists: ${row.workspace}, so it cannot continue. Create a new Session.`
3427
3663
  );
3428
3664
  }
3665
+ const knownSource = sources?.get(row.sessionId);
3429
3666
  try {
3430
3667
  return await agent.createSession({
3431
3668
  workspaceDir: row.workspace,
3432
3669
  modelId: row.modelId,
3433
- provider: row.provider
3670
+ provider: row.provider,
3671
+ // The rebuilt Session re-records a known origin in its fresh session_meta.
3672
+ ...knownSource != null ? { source: knownSource } : {}
3434
3673
  });
3435
3674
  } catch (err) {
3436
3675
  if (isMissingCredential(err)) throw modelCredentialMissing(row.modelId);
@@ -3520,6 +3759,10 @@ var SessionManager = class {
3520
3759
  pendingApprovals(sessionId) {
3521
3760
  return this.entries.get(sessionId)?.approvals.list() ?? [];
3522
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
+ }
3523
3766
  /** Number of Sessions for this Agent that are currently running / compacting. */
3524
3767
  activeCountForAgent(projectId, agentId) {
3525
3768
  let n = 0;
@@ -3542,6 +3785,7 @@ var SessionManager = class {
3542
3785
  abort: null,
3543
3786
  running: null,
3544
3787
  generation: this.generationOf(row.projectId, row.agentId),
3788
+ followUps: [],
3545
3789
  lastActivityMs: Date.now()
3546
3790
  });
3547
3791
  }
@@ -3563,38 +3807,84 @@ var SessionManager = class {
3563
3807
  * Start a Task: get-or-load → 409
3564
3808
  * mutual-exclusion check → publish the input messages first → drive run in the
3565
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.
3566
3816
  */
3567
- async startTask(sessionId, input) {
3817
+ async startTask(sessionId, input, opts) {
3568
3818
  return this.withLock(sessionId, async () => {
3569
3819
  this.assertOpen();
3570
3820
  this.assertAgentNotDeleting(sessionId);
3571
3821
  this.assertSessionNotDeleting(sessionId);
3572
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
+ }
3573
3832
  this.assertIdle(entry);
3574
- const channel = this.deps.channels.get(entry.sessionId);
3575
- const ac = new AbortController();
3576
- entry.status = "running";
3577
- entry.abort = ac;
3578
- entry.lastActivityMs = Date.now();
3579
- for (const msg of input) channel.publish(msg);
3580
- this.publishState(entry, "running");
3581
- const approve = makeApprove({
3582
- // Re-reads approval_mode from the DB on every decision (a PATCH takes effect immediately).
3583
- getMode: () => this.deps.sessions.findById(entry.sessionId)?.approvalMode ?? "always-ask",
3584
- toolPermission: (name) => entry.session.toolPermission(name),
3585
- registry: entry.approvals,
3586
- publishRequest: (pending) => this.publishEvent(entry, {
3587
- type: "approval_request",
3588
- toolCall: pending.toolCall,
3589
- ...pending.origin !== void 0 ? { origin: pending.origin } : {}
3590
- })
3591
- });
3592
- const gen = entry.session.run(input, { approve, signal: ac.signal });
3593
- const userExcerpt = input.filter(isPlainText("user")).map((m) => m.payload.text).join("\n");
3594
- entry.running = this.drive(entry, gen, { userExcerpt });
3595
- return { sessionId: entry.sessionId };
3833
+ this.launchTask(entry, input, opts?.thinkingLevel);
3834
+ return { sessionId: entry.sessionId, queued: false };
3596
3835
  });
3597
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
+ }
3598
3888
  /** Manually compact the context: 409 if already running; compaction output also flows into the SSE channel. */
3599
3889
  async startCompact(sessionId) {
3600
3890
  return this.withLock(sessionId, async () => {
@@ -3621,6 +3911,24 @@ var SessionManager = class {
3621
3911
  if (!entry) return false;
3622
3912
  return entry.approvals.decide(toolCallId, decision);
3623
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
+ }
3624
3932
  /**
3625
3933
  * Interrupt the current Task/compaction: pending approvals converge to deny first,
3626
3934
  * then the AbortSignal fires. Returns false if nothing is in progress (the route
@@ -3726,6 +4034,7 @@ var SessionManager = class {
3726
4034
  sweepIdle(now = Date.now(), idleMs = ENTRY_IDLE_MS) {
3727
4035
  for (const [key, entry] of this.entries) {
3728
4036
  if (entry.status !== "idle" || entry.approvals.size !== 0 || entry.running !== null) continue;
4037
+ if (entry.followUps.length > 0) continue;
3729
4038
  if (now - entry.lastActivityMs <= idleMs) continue;
3730
4039
  this.entries.delete(key);
3731
4040
  }
@@ -3817,6 +4126,7 @@ var SessionManager = class {
3817
4126
  abort: null,
3818
4127
  running: null,
3819
4128
  generation,
4129
+ followUps: [],
3820
4130
  lastActivityMs: Date.now()
3821
4131
  };
3822
4132
  this.entries.set(currentId, entry);
@@ -3921,6 +4231,7 @@ var SessionManager = class {
3921
4231
  fallbackText: titleSource.userExcerpt
3922
4232
  });
3923
4233
  }
4234
+ if (entry.followUps.length > 0) void this.startQueuedFollowUp(entry.sessionId);
3924
4235
  for (const child of children.values()) {
3925
4236
  if (!child.prompt.trim()) continue;
3926
4237
  this.deps.titles?.maybeGenerate(
@@ -3958,6 +4269,8 @@ var SessionManager = class {
3958
4269
  const p = msg.payload;
3959
4270
  const agentId = path8.basename(path8.dirname(p.agent_state));
3960
4271
  if (!agentId || agentId === "." || agentId === "..") return null;
4272
+ const source = asSessionSource(p.source) ?? "subagent";
4273
+ this.deps.sources.set(childSid, source);
3961
4274
  this.deps.sessions.insertOrIgnore({
3962
4275
  sessionId: childSid,
3963
4276
  projectId: entry.projectId,
@@ -3969,7 +4282,6 @@ var SessionManager = class {
3969
4282
  // inserted with defaults (matches the convention for Sessions discovered by the CLI).
3970
4283
  approvalMode: "allow-all",
3971
4284
  title: null,
3972
- source: "subagent",
3973
4285
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
3974
4286
  });
3975
4287
  this.publishEvent(entry, {
@@ -3977,7 +4289,7 @@ var SessionManager = class {
3977
4289
  projectId: entry.projectId,
3978
4290
  agentId,
3979
4291
  sessionId: childSid,
3980
- source: "subagent"
4292
+ source
3981
4293
  });
3982
4294
  const child = {
3983
4295
  sessionId: childSid,
@@ -3990,7 +4302,7 @@ var SessionManager = class {
3990
4302
  return child;
3991
4303
  }
3992
4304
  publishState(entry, state) {
3993
- this.publishEvent(entry, { type: "task_state", state });
4305
+ this.publishEvent(entry, { type: "task_state", state, queued: entry.followUps.length });
3994
4306
  }
3995
4307
  publishEvent(entry, event) {
3996
4308
  this.deps.channels.get(entry.sessionId).publish(event, "server_event");
@@ -4013,19 +4325,9 @@ var SessionManager = class {
4013
4325
  // src/runtime/scheduler.ts
4014
4326
  import { createHash as createHash3 } from "crypto";
4015
4327
  import { readdir } from "fs/promises";
4016
- import { agentsDir, userText as userText2 } from "@prismshadow/penguin-core";
4328
+ import { agentsDir, buildScheduledMessage, userText as userText2 } from "@prismshadow/penguin-core";
4017
4329
  var TICK_INTERVAL_MS = 3e4;
4018
- function scheduledMessage(name, firedAt, prompt) {
4019
- return [
4020
- "<scheduled_task>",
4021
- "This message was sent automatically by a scheduled task; its origin is listed below and the task prompt follows.",
4022
- `schedule: ${name}`,
4023
- `fired_at: ${firedAt}`,
4024
- "</scheduled_task>",
4025
- "",
4026
- prompt
4027
- ].join("\n");
4028
- }
4330
+ var scheduledMessage = buildScheduledMessage;
4029
4331
  var Scheduler = class {
4030
4332
  constructor(deps2) {
4031
4333
  this.deps = deps2;
@@ -4444,7 +4746,7 @@ var UsageRecorder = class {
4444
4746
  };
4445
4747
 
4446
4748
  // src/services/ids.ts
4447
- import { randomBytes as randomBytes3 } from "crypto";
4749
+ import { randomBytes as randomBytes4 } from "crypto";
4448
4750
  var SEMANTIC_ID_PATTERN = /^[a-z][a-z0-9_]{1,63}$/;
4449
4751
  var USERNAME_PATTERN = /^[a-z][a-z0-9_]{1,31}$/;
4450
4752
  var PROJECT_SUFFIX_PATTERN = /^[a-z0-9_]+$/;
@@ -4529,6 +4831,7 @@ import {
4529
4831
  VAULT_VALUE_MAX_LENGTH,
4530
4832
  isValidVaultKey,
4531
4833
  loadAgentVault,
4834
+ resetSystemConfigToDefaults,
4532
4835
  saveAgentVault,
4533
4836
  systemConfigPath
4534
4837
  } from "@prismshadow/penguin-core";
@@ -4774,6 +5077,7 @@ var ProjectConfigService = class {
4774
5077
  const cat = catalogEntryFor(provider, modelId);
4775
5078
  const envKey = resolveModelEnv(modelId, clientType)?.envKey;
4776
5079
  const vision = typeof m.vision === "boolean" ? m.vision : cat?.supportsVision;
5080
+ const maxTokens = optNum(m.max_tokens);
4777
5081
  const displayName = optStr(m.display_name) ?? cat?.displayName;
4778
5082
  const apiKey = optStr(m.api_key);
4779
5083
  const credBaseUrl = optStr(m.base_url);
@@ -4786,6 +5090,7 @@ var ProjectConfigService = class {
4786
5090
  ...optNum(m.context_window) !== void 0 ? { contextWindow: optNum(m.context_window) } : {},
4787
5091
  ...clientType ? { clientType } : {},
4788
5092
  ...vision !== void 0 ? { vision } : {},
5093
+ ...maxTokens !== void 0 ? { maxTokens } : {},
4789
5094
  ...envKey ? { envKey } : {},
4790
5095
  ...pricingDto ? { pricing: pricingDto } : {},
4791
5096
  ...apiKey !== void 0 || credBaseUrl !== void 0 ? {
@@ -4844,6 +5149,7 @@ var ProjectConfigService = class {
4844
5149
  delete next2.context_window;
4845
5150
  delete next2.client_type;
4846
5151
  delete next2.vision;
5152
+ delete next2.max_tokens;
4847
5153
  delete next2.pricing;
4848
5154
  delete next2.display_name;
4849
5155
  delete next2.request_model_id;
@@ -4854,6 +5160,7 @@ var ProjectConfigService = class {
4854
5160
  if (entry.contextWindow !== void 0) next2.context_window = entry.contextWindow;
4855
5161
  if (entry.clientType) next2.client_type = entry.clientType;
4856
5162
  if (entry.vision !== void 0) next2.vision = entry.vision;
5163
+ if (entry.maxTokens !== void 0) next2.max_tokens = entry.maxTokens;
4857
5164
  if (entry.pricing !== void 0) {
4858
5165
  next2.pricing = {
4859
5166
  unit: "usd_per_mtok",
@@ -4953,7 +5260,7 @@ function probeTps(outputTokens, windowMs) {
4953
5260
  }
4954
5261
 
4955
5262
  // src/services/agent-config-service.ts
4956
- var THINKING_LEVELS = ["none", "low", "medium", "high", "xhigh"];
5263
+ var THINKING_LEVELS2 = ["none", "low", "medium", "high", "xhigh"];
4957
5264
  var COMPACTION_MODES = ["summarize", "discard"];
4958
5265
  function asRecord(v) {
4959
5266
  return v !== null && typeof v === "object" && !Array.isArray(v) ? v : {};
@@ -5055,6 +5362,18 @@ var AgentConfigService = class {
5055
5362
  await fs10.writeFile(agentsMdPath(this.root, projectId, agentId), req.agentsMd, "utf8");
5056
5363
  }
5057
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
+ }
5058
5377
  async applyConfigUpdate(projectId, agentId, config2) {
5059
5378
  const cfg = config2;
5060
5379
  const yamlPath = systemConfigPath(this.root, projectId, agentId);
@@ -5083,7 +5402,7 @@ var AgentConfigService = class {
5083
5402
  );
5084
5403
  setIfProvided(
5085
5404
  ["model", "thinking_level"],
5086
- optionalEnum(model, "thinkingLevel", THINKING_LEVELS)
5405
+ optionalEnum(model, "thinkingLevel", THINKING_LEVELS2)
5087
5406
  );
5088
5407
  setIfProvided(
5089
5408
  ["model", "timeoutMs"],
@@ -5177,6 +5496,7 @@ function validateToolsBuiltin(value) {
5177
5496
  if (t.forModel !== void 0 && t.forModel !== "vision" && t.forModel !== "text-only") {
5178
5497
  throw badRequest(`toolsBuiltin[${i}].forModel must be one of vision / text-only.`);
5179
5498
  }
5499
+ optionalBoolean(t, "call_description", `toolsBuiltin[${i}].call_description`);
5180
5500
  optionalNumber(t, "timeoutMs", {
5181
5501
  integer: true,
5182
5502
  positiveOrMinusOne: true,
@@ -5555,7 +5875,7 @@ var BenchmarkService = class {
5555
5875
  };
5556
5876
 
5557
5877
  // src/services/snapshot-service.ts
5558
- import { randomBytes as randomBytes4 } from "crypto";
5878
+ import { randomBytes as randomBytes5 } from "crypto";
5559
5879
  import fs13 from "fs/promises";
5560
5880
  import path12 from "path";
5561
5881
  import * as tar from "tar";
@@ -5601,7 +5921,7 @@ var SnapshotService = class {
5601
5921
  } catch {
5602
5922
  }
5603
5923
  await fs13.mkdir(dir, { recursive: true });
5604
- const tmp = `${file}.tmp-${randomBytes4(4).toString("hex")}`;
5924
+ const tmp = `${file}.tmp-${randomBytes5(4).toString("hex")}`;
5605
5925
  await tar.create(
5606
5926
  {
5607
5927
  gzip: true,
@@ -5629,7 +5949,7 @@ var SnapshotService = class {
5629
5949
  async importArchive(projectId, agentId, archive, confirm) {
5630
5950
  const current = await this.currentVersion(projectId, agentId);
5631
5951
  const base = agentDir2(this.root, projectId, agentId);
5632
- const staging = path12.join(base, `.import-${randomBytes4(6).toString("hex")}`);
5952
+ const staging = path12.join(base, `.import-${randomBytes5(6).toString("hex")}`);
5633
5953
  await fs13.mkdir(staging, { recursive: true });
5634
5954
  try {
5635
5955
  const archiveFile = path12.join(staging, "archive.tar.gz");
@@ -5983,15 +6303,39 @@ var ProjectService = class {
5983
6303
 
5984
6304
  // src/services/session-service.ts
5985
6305
  import path13 from "path";
5986
- import { readdir as readdir2 } from "fs/promises";
6306
+ import { open, readdir as readdir2 } from "fs/promises";
5987
6307
  import {
5988
6308
  createAgent as createAgent2,
6309
+ findLatestTraceFile as findLatestTraceFile2,
5989
6310
  isSessionMeta as isSessionMeta4,
6311
+ parseTraceLines,
5990
6312
  readTraceTolerant,
5991
6313
  tracesDir as tracesDir2
5992
6314
  } from "@prismshadow/penguin-core";
5993
6315
  var TRACE_FILE_RE = /^(.+)_(\d{3})\.jsonl$/;
5994
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
+ }
5995
6339
  function sessionIdCreatedAt(sessionId) {
5996
6340
  const m = SESSION_ID_TS_RE.exec(sessionId);
5997
6341
  if (!m) return null;
@@ -6004,8 +6348,14 @@ var SessionService = class {
6004
6348
  this.deps = deps2;
6005
6349
  }
6006
6350
  deps;
6007
- /** DB row -> SessionInfo (run status and pending approval count come from session-manager). */
6008
- toInfo(row, hasTrace) {
6351
+ /**
6352
+ * DB row -> SessionInfo (run status and pending approval count come from session-manager).
6353
+ * Async because `source` is derived from session_meta: a registry miss (Session predating
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.
6356
+ */
6357
+ async toInfo(row, hasTrace, traces) {
6358
+ const source = await this.sourceOf(row, hasTrace, traces);
6009
6359
  return {
6010
6360
  sessionId: row.sessionId,
6011
6361
  projectId: row.projectId,
@@ -6015,33 +6365,106 @@ var SessionService = class {
6015
6365
  workspace: row.workspace,
6016
6366
  approvalMode: row.approvalMode,
6017
6367
  ...row.title !== null ? { title: row.title } : {},
6018
- ...row.source != null ? { source: row.source } : {},
6368
+ ...source !== void 0 ? { source } : {},
6019
6369
  createdAt: row.createdAt,
6020
6370
  status: this.deps.manager.statusOf(row.sessionId),
6021
6371
  pendingApprovalCount: this.deps.manager.pendingApprovalCount(row.sessionId),
6372
+ pendingFollowUpCount: this.deps.manager.pendingFollowUpCount(row.sessionId),
6022
6373
  hasTrace,
6023
6374
  archived: (row.archivedAt ?? null) !== null
6024
6375
  };
6025
6376
  }
6377
+ /**
6378
+ * A Session's origin, with session_meta as the single source of truth: the in-process
6379
+ * registry answers first (populated at creation / subagent registration / adoption);
6380
+ * on a miss (a Session created before this process started) the earliest Trace shard's
6381
+ * session_meta is read once and cached. A Session with no Trace yet stays unknown and
6382
+ * is NOT cached negatively — its meta may appear with the first run.
6383
+ */
6384
+ async sourceOf(row, hasTrace, traces) {
6385
+ const known = this.deps.sources.get(row.sessionId);
6386
+ if (known !== void 0) return known ?? void 0;
6387
+ if (!hasTrace) return void 0;
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);
6391
+ if (!meta) return void 0;
6392
+ const source = asSessionSource(meta.payload.source) ?? null;
6393
+ this.deps.sources.set(row.sessionId, source);
6394
+ return source ?? void 0;
6395
+ }
6026
6396
  /** Whether this Session already has a Trace record (a Task has been run). */
6027
6397
  async hasTrace(row) {
6028
- const ids = await this.discoverTraceSessionIds(row.projectId, row.agentId);
6029
- return ids.has(row.sessionId);
6398
+ return (await this.discoverTraces(row.projectId, row.agentId)).has(row.sessionId);
6030
6399
  }
6031
- /** List: DB ∪ Trace directory discovery, sorted by createdAt descending. */
6032
- async listSessions(projectId, agentId) {
6033
- const traceIds = await this.discoverTraceSessionIds(projectId, agentId);
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";
6409
+ }
6410
+ /**
6411
+ * List: DB ∪ Trace directory discovery, sorted by createdAt descending. Optional
6412
+ * `paging` returns just that slice (the sidebar pages with limit+1 to detect "has
6413
+ * more"); slicing happens before toInfo, so per-request source derivation (lazy
6414
+ * Trace-head reads) stays bounded by the page size. Discovery/adoption still scans
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.
6424
+ */
6425
+ async listSessions(projectId, agentId, opts = {}) {
6426
+ const { paging, category, withCounts } = opts;
6427
+ const traces = await this.discoverTraces(projectId, agentId);
6034
6428
  const rows = new Map(
6035
6429
  this.deps.sessions.listByAgent(projectId, agentId).map((r) => [r.sessionId, r])
6036
6430
  );
6037
- for (const sessionId of traceIds) {
6431
+ for (const [sessionId, location] of traces) {
6038
6432
  if (rows.has(sessionId)) continue;
6039
- const discovered = await this.adoptTraceSession(projectId, agentId, sessionId);
6433
+ const discovered = await this.adoptTraceSession(projectId, agentId, sessionId, location);
6040
6434
  if (discovered) rows.set(sessionId, discovered);
6041
6435
  }
6042
- return [...rows.values()].sort(
6436
+ const sorted = [...rows.values()].sort(
6043
6437
  (a, b) => b.createdAt.localeCompare(a.createdAt) || b.sessionId.localeCompare(a.sessionId)
6044
- ).map((row) => this.toInfo(row, traceIds.has(row.sessionId)));
6438
+ );
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 };
6045
6468
  }
6046
6469
  /**
6047
6470
  * Session stats (Agents list card): total count = size of the union of DB index
@@ -6121,7 +6544,9 @@ var SessionService = class {
6121
6544
  session = await agent.createSession({
6122
6545
  modelId,
6123
6546
  provider,
6124
- ...args.workspace !== void 0 ? { workspaceDir: args.workspace } : {}
6547
+ ...args.workspace !== void 0 ? { workspaceDir: args.workspace } : {},
6548
+ // The origin is also recorded in core session_meta (Trace), not just the index row.
6549
+ ...args.source !== void 0 ? { source: args.source } : {}
6125
6550
  });
6126
6551
  } catch (err) {
6127
6552
  if (isMissingCredential(err)) throw modelCredentialMissing(modelId);
@@ -6131,6 +6556,11 @@ var SessionService = class {
6131
6556
  err instanceof Error ? err.message : String(err)
6132
6557
  );
6133
6558
  }
6559
+ const metaMsg = session.metaMessage;
6560
+ this.deps.sources.set(
6561
+ session.sessionId,
6562
+ isSessionMeta4(metaMsg) ? asSessionSource(metaMsg.payload.source) ?? null : null
6563
+ );
6134
6564
  const row = {
6135
6565
  sessionId: session.sessionId,
6136
6566
  projectId: args.projectId,
@@ -6140,49 +6570,68 @@ var SessionService = class {
6140
6570
  workspace: session.workspaceDir,
6141
6571
  approvalMode: args.approvalMode ?? "allow-all",
6142
6572
  title: null,
6143
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
6144
- ...args.source !== void 0 ? { source: args.source } : {}
6573
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
6145
6574
  };
6146
6575
  this.deps.sessions.insert(row);
6147
6576
  this.deps.manager.adopt(row, session);
6148
6577
  return this.toInfo(row, false);
6149
6578
  }
6150
- /** Scans the Trace directory to get the set of session_ids with records. */
6151
- async discoverTraceSessionIds(projectId, agentId) {
6152
- const dir = tracesDir2(this.deps.root, projectId, agentId);
6153
- const ids = /* @__PURE__ */ new Set();
6154
- for (const dateDir of await listDirsSafe(dir)) {
6155
- for (const file of await listFilesSafe(path13.join(dir, dateDir))) {
6156
- const match = TRACE_FILE_RE.exec(file);
6157
- if (match) ids.add(match[1]);
6158
- }
6159
- }
6160
- 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;
6161
6592
  }
6162
- /** Adopts a Session that exists only in the Trace directory: reads session_meta from the first line of the earliest index file. */
6163
- async adoptTraceSession(projectId, agentId, sessionId) {
6593
+ /**
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.
6598
+ */
6599
+ async discoverTraces(projectId, agentId) {
6164
6600
  const dir = tracesDir2(this.deps.root, projectId, agentId);
6165
- let earliest = null;
6601
+ const out = /* @__PURE__ */ new Map();
6166
6602
  for (const dateDir of await listDirsSafe(dir)) {
6167
6603
  for (const file of await listFilesSafe(path13.join(dir, dateDir))) {
6168
6604
  const match = TRACE_FILE_RE.exec(file);
6169
- if (!match || match[1] !== sessionId) continue;
6605
+ if (!match) continue;
6606
+ const sessionId = match[1];
6170
6607
  const index = Number(match[2]);
6171
- if (!earliest || index < earliest.index) {
6172
- 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 });
6173
6611
  }
6174
6612
  }
6175
6613
  }
6176
- 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) {
6177
6621
  let messages;
6178
6622
  try {
6179
- messages = await readTraceTolerant(earliest.path);
6623
+ messages = await readTraceHead(location.path);
6180
6624
  } catch {
6181
6625
  return null;
6182
6626
  }
6183
- const meta = messages.find(isSessionMeta4);
6627
+ return messages.find(isSessionMeta4) ?? null;
6628
+ }
6629
+ /** Adopts a Session that exists only in the Trace directory: reads session_meta from the first line of the earliest index file. */
6630
+ async adoptTraceSession(projectId, agentId, sessionId, location) {
6631
+ const meta = await this.readTraceMeta(location);
6184
6632
  if (!meta) return null;
6185
6633
  if (typeof meta.payload.provider !== "string") return null;
6634
+ this.deps.sources.set(sessionId, asSessionSource(meta.payload.source) ?? null);
6186
6635
  const row = {
6187
6636
  sessionId,
6188
6637
  projectId,
@@ -6223,7 +6672,12 @@ async function listFilesSafe(dir) {
6223
6672
  // src/services/trace-service.ts
6224
6673
  import fs15 from "fs/promises";
6225
6674
  import path14 from "path";
6226
- 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";
6227
6681
  var TRACE_FILE_RE2 = /^(.+)_(\d{3})\.jsonl$/;
6228
6682
  var MAX_SUBAGENT_DEPTH = 4;
6229
6683
  function subagentPointer(msg) {
@@ -6414,7 +6868,9 @@ var TraceService = class {
6414
6868
  const msg = messages[mi];
6415
6869
  const p = msg.payload;
6416
6870
  const hasOrigin = msg.origin !== void 0 && msg.origin.length > 0;
6417
- 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");
6418
6874
  const startsCompactionTurn = !hasOrigin && msg.type === "event_msg" && p.type === "compaction_begin";
6419
6875
  if (startsUserTurn || startsCompactionTurn) {
6420
6876
  if (pendingFrom === null) pendingFrom = mi;
@@ -6813,6 +7269,53 @@ var UsageService = class {
6813
7269
  }
6814
7270
  };
6815
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
+
6816
7319
  // src/app.ts
6817
7320
  var MAX_BODY_BYTES = 20 * 1024 * 1024;
6818
7321
  function buildAppDeps(config2, overrides = {}) {
@@ -6833,6 +7336,7 @@ function buildAppDeps(config2, overrides = {}) {
6833
7336
  const agentService = new AgentService(config2.root, agentsRepo, agentConfigService);
6834
7337
  const traceService = new TraceService(config2.root);
6835
7338
  const workspaceFiles = new WorkspaceFilesService();
7339
+ const previewTokens = createPreviewTokenSigner();
6836
7340
  const benchmarks = new BenchmarkService(config2.root);
6837
7341
  const snapshots = new SnapshotService(config2.root);
6838
7342
  const usageService = new UsageService(
@@ -6848,10 +7352,12 @@ function buildAppDeps(config2, overrides = {}) {
6848
7352
  const recorder = new UsageRecorder(usageRepo, overrides.now ?? (() => /* @__PURE__ */ new Date()));
6849
7353
  const errors = new ErrorRecorder(errorsRepo, overrides.now ?? (() => /* @__PURE__ */ new Date()));
6850
7354
  const titles = overrides.titles ?? new TitleGenerator({ sessions: sessionsRepo, channels, recorder, errors, log });
7355
+ const sessionSources = new SessionSources();
6851
7356
  const manager = new SessionManager({
6852
7357
  sessions: sessionsRepo,
6853
7358
  channels,
6854
- loader: overrides.loader ?? createCoreSessionLoader(config2.root),
7359
+ loader: overrides.loader ?? createCoreSessionLoader(config2.root, sessionSources),
7360
+ sources: sessionSources,
6855
7361
  recorder,
6856
7362
  errors,
6857
7363
  titles,
@@ -6890,7 +7396,8 @@ function buildAppDeps(config2, overrides = {}) {
6890
7396
  root: config2.root,
6891
7397
  sessions: sessionsRepo,
6892
7398
  manager,
6893
- projectConfig: projectConfigService
7399
+ projectConfig: projectConfigService,
7400
+ sources: sessionSources
6894
7401
  });
6895
7402
  const scheduler = new Scheduler({
6896
7403
  root: config2.root,
@@ -6920,18 +7427,20 @@ function buildAppDeps(config2, overrides = {}) {
6920
7427
  traceService,
6921
7428
  usageService,
6922
7429
  workspaceFiles,
7430
+ previewTokens,
6923
7431
  benchmarks,
6924
7432
  snapshots,
6925
7433
  schedulesRepo,
6926
7434
  scheduler,
6927
7435
  channels,
6928
7436
  manager,
7437
+ sessionSources,
6929
7438
  errors,
6930
7439
  log
6931
7440
  };
6932
7441
  }
6933
7442
  function createApp(deps2) {
6934
- const app2 = new Hono19();
7443
+ const app2 = new Hono20();
6935
7444
  app2.onError((err, c) => {
6936
7445
  const projectId = attributedProjectId(c, deps2);
6937
7446
  deps2.errors.record({
@@ -6948,6 +7457,21 @@ function createApp(deps2) {
6948
7457
  const ms = Math.round(performance.now() - start);
6949
7458
  deps2.log(`${c.req.method} ${c.req.path} ${c.res.status} ${ms}ms`);
6950
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
+ }
6951
7475
  app2.use("/api/*", async (c, next) => {
6952
7476
  const contentLength = Number(c.req.header("content-length") ?? 0);
6953
7477
  if (contentLength > MAX_BODY_BYTES) {
@@ -6978,6 +7502,7 @@ function createApp(deps2) {
6978
7502
  app2.route("/api/projects/:projectId/agents/:agentId/sessions", agentSessionsRoutes(deps2));
6979
7503
  app2.route("/api/projects/:projectId/usage", usageRoutes(deps2));
6980
7504
  app2.route("/api/sessions", sessionsRoutes(deps2));
7505
+ app2.route("/preview", previewRoutes(deps2));
6981
7506
  if (fs16.existsSync(deps2.config.webDist)) {
6982
7507
  registerStaticRoutes(app2, deps2.config.webDist);
6983
7508
  }
@@ -7043,7 +7568,7 @@ function registerStaticRoutes(app2, webDist) {
7043
7568
  import fs17 from "fs";
7044
7569
  import path16 from "path";
7045
7570
  import { fileURLToPath } from "url";
7046
- import { resolveRoot } from "@prismshadow/penguin-core";
7571
+ import { DEFAULT_SERVER_PORT, resolveRoot } from "@prismshadow/penguin-core";
7047
7572
  var DAY_MS = 24 * 60 * 60 * 1e3;
7048
7573
  function defaultWebDist() {
7049
7574
  const here = path16.dirname(fileURLToPath(import.meta.url));
@@ -7051,9 +7576,22 @@ function defaultWebDist() {
7051
7576
  if (fs17.existsSync(bundled)) return bundled;
7052
7577
  return path16.resolve(here, "..", "..", "web", "dist");
7053
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
+ }
7054
7592
  function resolveServerConfig(env = process.env) {
7055
7593
  const root = env.PENGUIN_HOME ?? resolveRoot();
7056
- const port = Number(env.PORT || 7364);
7594
+ const port = Number(env.PORT || DEFAULT_SERVER_PORT);
7057
7595
  if (!Number.isInteger(port) || port < 0 || port > 65535) {
7058
7596
  throw new Error(`Invalid port configuration PORT=${env.PORT}`);
7059
7597
  }
@@ -7063,6 +7601,7 @@ function resolveServerConfig(env = process.env) {
7063
7601
  port,
7064
7602
  dbPath: env.PENGUIN_WEB_DB ?? path16.join(root, "web.db"),
7065
7603
  webDist: env.PENGUIN_WEB_DIST ?? defaultWebDist(),
7604
+ previewOrigin: normalizePreviewOrigin(env.PENGUIN_PREVIEW_ORIGIN),
7066
7605
  authSessionTtlMs: 7 * DAY_MS,
7067
7606
  authSessionRenewMs: 6 * DAY_MS
7068
7607
  };
@@ -7075,11 +7614,18 @@ var deps = buildAppDeps(config);
7075
7614
  var app = createApp(deps);
7076
7615
  await deps.authService.seedAdmin();
7077
7616
  await deps.scheduler.start();
7617
+ var appHost = loopbackHostRoles(config.host)?.app ?? config.host;
7078
7618
  var server = serve({ fetch: app.fetch, hostname: config.host, port: config.port }, (info) => {
7079
- console.log(`penguin-server started: http://${config.host}:${info.port}`);
7619
+ console.log(`penguin-server started: http://${appHost}:${info.port}`);
7080
7620
  console.log(`Data root: ${config.root}`);
7081
7621
  console.log(`SQLite: ${config.dbPath}`);
7082
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
+ });
7083
7629
  var shuttingDown = false;
7084
7630
  async function shutdown(signal, exitCode = 0) {
7085
7631
  if (shuttingDown) return;
@@ -7088,6 +7634,7 @@ async function shutdown(signal, exitCode = 0) {
7088
7634
  deps.scheduler.stop();
7089
7635
  await deps.manager.shutdown(5e3);
7090
7636
  deps.channels.dispose();
7637
+ ipv6Loopback?.close();
7091
7638
  server.close(() => {
7092
7639
  deps.db.close();
7093
7640
  process.exit(exitCode);