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