@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/api/types.d.ts +75 -4
- package/dist/index.js +661 -114
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/web-dist/assets/index-BwOLjdOw.css +1 -0
- package/web-dist/assets/index-Ddrpqgqu.js +104 -0
- package/web-dist/assets/{index-B-18IFfR.js → index-zCEa-kcQ.js} +1 -1
- package/web-dist/index.html +2 -2
- package/web-dist/assets/index-BfFhfbzq.css +0 -1
- package/web-dist/assets/index-BhliHHUc.js +0 -101
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
|
|
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,
|
|
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,
|
|
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
|
|
1077
|
-
if (
|
|
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
|
-
|
|
1147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2660
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
|
2843
|
-
const
|
|
2844
|
-
|
|
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
|
-
|
|
3575
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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",
|
|
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
|
|
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-${
|
|
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-${
|
|
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
|
-
/**
|
|
6008
|
-
|
|
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
|
-
...
|
|
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
|
-
|
|
6029
|
-
return ids.has(row.sessionId);
|
|
6398
|
+
return (await this.discoverTraces(row.projectId, row.agentId)).has(row.sessionId);
|
|
6030
6399
|
}
|
|
6031
|
-
/**
|
|
6032
|
-
|
|
6033
|
-
|
|
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
|
|
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
|
-
|
|
6436
|
+
const sorted = [...rows.values()].sort(
|
|
6043
6437
|
(a, b) => b.createdAt.localeCompare(a.createdAt) || b.sessionId.localeCompare(a.sessionId)
|
|
6044
|
-
)
|
|
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
|
-
/**
|
|
6151
|
-
|
|
6152
|
-
|
|
6153
|
-
|
|
6154
|
-
|
|
6155
|
-
|
|
6156
|
-
|
|
6157
|
-
|
|
6158
|
-
|
|
6159
|
-
|
|
6160
|
-
|
|
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
|
-
/**
|
|
6163
|
-
|
|
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
|
-
|
|
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
|
|
6605
|
+
if (!match) continue;
|
|
6606
|
+
const sessionId = match[1];
|
|
6170
6607
|
const index = Number(match[2]);
|
|
6171
|
-
|
|
6172
|
-
|
|
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
|
-
|
|
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
|
|
6623
|
+
messages = await readTraceHead(location.path);
|
|
6180
6624
|
} catch {
|
|
6181
6625
|
return null;
|
|
6182
6626
|
}
|
|
6183
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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 ||
|
|
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://${
|
|
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);
|