@prismshadow/penguin-server 0.2.3 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/api/types.d.ts +242 -28
- package/dist/{chunk-3QINBW3W.js → chunk-KQQ3HSTK.js} +11 -8
- package/dist/chunk-KQQ3HSTK.js.map +1 -0
- package/dist/hmr/manifest.d.ts +10 -0
- package/dist/hmr/manifest.js.map +1 -1
- package/dist/index.js +2604 -349
- package/dist/index.js.map +1 -1
- package/dist/reset-admin-password.js +1 -1
- package/package.json +7 -3
- package/web-dist/assets/addon-fit-ClUHZ6NU.js +1 -0
- package/web-dist/assets/addon-web-links-BKMSMMvN.js +1 -0
- package/web-dist/assets/{highlighter-Nvj8c0Yw.js → highlighter-Cp5vYdaX.js} +1 -1
- package/web-dist/assets/index-BJNmFD1m.js +187 -0
- package/web-dist/assets/index-BneOZQwC.css +1 -0
- package/web-dist/assets/xterm-j28bzvOQ.js +9 -0
- package/web-dist/index.html +2 -2
- package/dist/chunk-3QINBW3W.js.map +0 -1
- package/web-dist/assets/index--uKtXcEB.js +0 -183
- package/web-dist/assets/index-BqF51KT4.css +0 -1
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
AuthSessionsRepo,
|
|
5
5
|
HttpError,
|
|
6
6
|
MIN_PASSWORD_LENGTH,
|
|
7
|
+
SCRYPT_COST,
|
|
7
8
|
UsersRepo,
|
|
8
9
|
errorBody,
|
|
9
10
|
handleError,
|
|
@@ -12,7 +13,7 @@ import {
|
|
|
12
13
|
modelCredentialMissing,
|
|
13
14
|
openDatabase,
|
|
14
15
|
toUserInfo
|
|
15
|
-
} from "./chunk-
|
|
16
|
+
} from "./chunk-KQQ3HSTK.js";
|
|
16
17
|
import {
|
|
17
18
|
acquireServerLock,
|
|
18
19
|
liveServerLock,
|
|
@@ -26,16 +27,16 @@ import {
|
|
|
26
27
|
} from "./chunk-UUJCF46V.js";
|
|
27
28
|
|
|
28
29
|
// src/index.ts
|
|
29
|
-
import
|
|
30
|
-
import
|
|
30
|
+
import fs25 from "fs";
|
|
31
|
+
import path24 from "path";
|
|
31
32
|
import { config as loadDotenv } from "dotenv";
|
|
32
33
|
import { serve } from "@hono/node-server";
|
|
33
34
|
|
|
34
35
|
// src/app.ts
|
|
35
|
-
import
|
|
36
|
-
import
|
|
37
|
-
import
|
|
38
|
-
import { Hono as
|
|
36
|
+
import fs23 from "fs";
|
|
37
|
+
import fsp3 from "fs/promises";
|
|
38
|
+
import path22 from "path";
|
|
39
|
+
import { Hono as Hono28 } from "hono";
|
|
39
40
|
import { bodyLimit } from "hono/body-limit";
|
|
40
41
|
|
|
41
42
|
// src/net/proxy.ts
|
|
@@ -987,6 +988,13 @@ var UiPrefsRepo = class {
|
|
|
987
988
|
};
|
|
988
989
|
|
|
989
990
|
// src/db/repos/usage.ts
|
|
991
|
+
var BUCKET_EXPRS = {
|
|
992
|
+
minute: `strftime('%Y-%m-%dT%H:%M', ts, 'localtime')`,
|
|
993
|
+
hour: `strftime('%Y-%m-%dT%H:00', ts, 'localtime')`,
|
|
994
|
+
day: "date",
|
|
995
|
+
week: `date(date, '-6 days', 'weekday 1')`,
|
|
996
|
+
month: "substr(date, 1, 7)"
|
|
997
|
+
};
|
|
990
998
|
var GROUP_COLUMNS = {
|
|
991
999
|
date: "date",
|
|
992
1000
|
agent: "agent_id",
|
|
@@ -1048,6 +1056,14 @@ var UsageRepo = class {
|
|
|
1048
1056
|
conds.push("date <= :to");
|
|
1049
1057
|
params.to = f.to;
|
|
1050
1058
|
}
|
|
1059
|
+
if (f.fromTs !== void 0) {
|
|
1060
|
+
conds.push("ts >= :fromTs");
|
|
1061
|
+
params.fromTs = f.fromTs;
|
|
1062
|
+
}
|
|
1063
|
+
if (f.toTs !== void 0) {
|
|
1064
|
+
conds.push("ts <= :toTs");
|
|
1065
|
+
params.toTs = f.toTs;
|
|
1066
|
+
}
|
|
1051
1067
|
if (f.agentId !== void 0) {
|
|
1052
1068
|
conds.push("agent_id = :agentId");
|
|
1053
1069
|
params.agentId = f.agentId;
|
|
@@ -1084,34 +1100,45 @@ var UsageRepo = class {
|
|
|
1084
1100
|
return rows.map((r) => ({ key: r.key, ...toSums(r) }));
|
|
1085
1101
|
}
|
|
1086
1102
|
/**
|
|
1087
|
-
*
|
|
1088
|
-
* powers the cost center's
|
|
1089
|
-
* (
|
|
1090
|
-
*
|
|
1091
|
-
* toward the denominator (conservative: anything non-completed counts as a failure).
|
|
1103
|
+
* Time-series sums (bucket key x paired reference breakdown) with per-bucket
|
|
1104
|
+
* success-rate counts riding along: powers the cost center's time-series charts
|
|
1105
|
+
* (requests / success rate / Token / cost) at the requested precision. The
|
|
1106
|
+
* denominator excludes aborted.
|
|
1092
1107
|
*/
|
|
1093
|
-
|
|
1108
|
+
seriesByModel(projectId, granularity, f = {}) {
|
|
1109
|
+
const expr = BUCKET_EXPRS[granularity];
|
|
1110
|
+
const { where, params } = this.conds(projectId, f);
|
|
1111
|
+
const rows = this.db.prepare(
|
|
1112
|
+
`SELECT ${expr} AS key, provider, model_id, ${SUM_COLUMNS},
|
|
1113
|
+
COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0) AS completed,
|
|
1114
|
+
COALESCE(SUM(CASE WHEN status <> 'aborted' THEN 1 ELSE 0 END), 0) AS denominator
|
|
1115
|
+
FROM usage_records WHERE ${where}
|
|
1116
|
+
GROUP BY key, provider, model_id`
|
|
1117
|
+
).all(params);
|
|
1118
|
+
return rows.map((r) => ({
|
|
1119
|
+
key: r.key,
|
|
1120
|
+
...toSums(r),
|
|
1121
|
+
completed: r.completed,
|
|
1122
|
+
denominator: r.denominator
|
|
1123
|
+
}));
|
|
1124
|
+
}
|
|
1125
|
+
/** Per-Agent counts per time bucket (the by-Agent requests chart's series; the denominator excludes aborted). */
|
|
1126
|
+
agentSeries(projectId, granularity, f = {}) {
|
|
1127
|
+
const expr = BUCKET_EXPRS[granularity];
|
|
1094
1128
|
const { where, params } = this.conds(projectId, f);
|
|
1095
|
-
const count = (status) => `COALESCE(SUM(CASE WHEN status = '${status}' THEN 1 ELSE 0 END), 0) AS ${status}`;
|
|
1096
1129
|
const rows = this.db.prepare(
|
|
1097
|
-
`SELECT
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
${count("malformed")},
|
|
1103
|
-
COALESCE(SUM(CASE WHEN status <> 'aborted' THEN 1 ELSE 0 END), 0) AS total
|
|
1104
|
-
FROM usage_records WHERE ${where} GROUP BY provider, model_id`
|
|
1130
|
+
`SELECT ${expr} AS key, agent_id, COUNT(*) AS requests,
|
|
1131
|
+
COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0) AS completed,
|
|
1132
|
+
COALESCE(SUM(CASE WHEN status <> 'aborted' THEN 1 ELSE 0 END), 0) AS denominator
|
|
1133
|
+
FROM usage_records WHERE ${where}
|
|
1134
|
+
GROUP BY key, agent_id`
|
|
1105
1135
|
).all(params);
|
|
1106
1136
|
return rows.map((r) => ({
|
|
1107
|
-
|
|
1108
|
-
|
|
1137
|
+
key: r.key,
|
|
1138
|
+
agentId: r.agent_id,
|
|
1139
|
+
requests: r.requests,
|
|
1109
1140
|
completed: r.completed,
|
|
1110
|
-
|
|
1111
|
-
aborted: r.aborted,
|
|
1112
|
-
failed: r.failed,
|
|
1113
|
-
timeout: r.timeout,
|
|
1114
|
-
malformed: r.malformed
|
|
1141
|
+
denominator: r.denominator
|
|
1115
1142
|
}));
|
|
1116
1143
|
}
|
|
1117
1144
|
/** Distinct agent_id values seen for this Project (for filter dropdowns). */
|
|
@@ -1169,6 +1196,13 @@ var jsonOnlyWrites = async (c, next) => {
|
|
|
1169
1196
|
await next();
|
|
1170
1197
|
};
|
|
1171
1198
|
|
|
1199
|
+
// src/platform/terminal/identity.ts
|
|
1200
|
+
var IDENTITY_RESOURCE_ID = "runtime:identity";
|
|
1201
|
+
function identityFrom(resources) {
|
|
1202
|
+
const resolver = resources.claim(IDENTITY_RESOURCE_ID);
|
|
1203
|
+
return resolver ?? (async () => null);
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1172
1206
|
// src/http/routes/admin.ts
|
|
1173
1207
|
import { Hono as Hono2 } from "hono";
|
|
1174
1208
|
|
|
@@ -1202,6 +1236,40 @@ function desktopRoutes(deps2) {
|
|
|
1202
1236
|
});
|
|
1203
1237
|
return app2;
|
|
1204
1238
|
}
|
|
1239
|
+
function desktopUpdateRoutes(deps2) {
|
|
1240
|
+
const app2 = new Hono();
|
|
1241
|
+
const requireShellSession = (c) => {
|
|
1242
|
+
const desktop = deps2.desktop;
|
|
1243
|
+
if (!desktop) throw new HttpError(404, "not_found", "Desktop mode is not enabled.");
|
|
1244
|
+
if (c.var.sessionVia !== "desktop") {
|
|
1245
|
+
throw new HttpError(
|
|
1246
|
+
403,
|
|
1247
|
+
"desktop_shell_only",
|
|
1248
|
+
"Client updates are managed from the desktop app's own window."
|
|
1249
|
+
);
|
|
1250
|
+
}
|
|
1251
|
+
return desktop;
|
|
1252
|
+
};
|
|
1253
|
+
app2.get("/", (c) => {
|
|
1254
|
+
const desktop = requireShellSession(c);
|
|
1255
|
+
return c.json({ status: desktop.getUpdateStatus() });
|
|
1256
|
+
});
|
|
1257
|
+
app2.post("/check", (c) => {
|
|
1258
|
+
const desktop = requireShellSession(c);
|
|
1259
|
+
if (!desktop.requestUpdateCommand("check")) {
|
|
1260
|
+
throw new HttpError(503, "shell_unreachable", "The desktop shell is not listening.");
|
|
1261
|
+
}
|
|
1262
|
+
return c.body(null, 202);
|
|
1263
|
+
});
|
|
1264
|
+
app2.post("/install", (c) => {
|
|
1265
|
+
const desktop = requireShellSession(c);
|
|
1266
|
+
if (!desktop.requestUpdateCommand("install")) {
|
|
1267
|
+
throw new HttpError(503, "shell_unreachable", "The desktop shell is not listening.");
|
|
1268
|
+
}
|
|
1269
|
+
return c.body(null, 202);
|
|
1270
|
+
});
|
|
1271
|
+
return app2;
|
|
1272
|
+
}
|
|
1205
1273
|
|
|
1206
1274
|
// src/http/validate.ts
|
|
1207
1275
|
import { isValidId } from "@prismshadow/penguin-core";
|
|
@@ -1573,6 +1641,48 @@ function resolvePreviewTarget(requestUrl, hostHeader, configuredOrigin, serverBi
|
|
|
1573
1641
|
return { origin: `${protocol}//${counterpart}${suffix}`, host: counterpart };
|
|
1574
1642
|
}
|
|
1575
1643
|
|
|
1644
|
+
// src/services/draft-shortcuts.ts
|
|
1645
|
+
var DRAFT_SHORTCUT_MAX_COUNT = 3;
|
|
1646
|
+
var DRAFT_SHORTCUT_TITLE_MAX = 40;
|
|
1647
|
+
var DRAFT_SHORTCUT_PROMPT_MAX = 4e3;
|
|
1648
|
+
var DRAFT_SHORTCUT_ID_MAX = 64;
|
|
1649
|
+
function invalid(message) {
|
|
1650
|
+
return new HttpError(400, "invalid_draft_shortcuts", message);
|
|
1651
|
+
}
|
|
1652
|
+
function shortcutField(entry, key, index, maxLen) {
|
|
1653
|
+
const value = entry[key];
|
|
1654
|
+
if (typeof value !== "string") {
|
|
1655
|
+
throw invalid(`draftShortcuts[${index}].${key} must be a string.`);
|
|
1656
|
+
}
|
|
1657
|
+
const trimmed = value.trim();
|
|
1658
|
+
if (trimmed === "") throw invalid(`draftShortcuts[${index}].${key} must not be empty.`);
|
|
1659
|
+
if (trimmed.length > maxLen) {
|
|
1660
|
+
throw invalid(`draftShortcuts[${index}].${key} must be at most ${maxLen} characters.`);
|
|
1661
|
+
}
|
|
1662
|
+
return trimmed;
|
|
1663
|
+
}
|
|
1664
|
+
function validateDraftShortcuts(value) {
|
|
1665
|
+
if (!Array.isArray(value)) throw invalid("draftShortcuts must be an array.");
|
|
1666
|
+
if (value.length > DRAFT_SHORTCUT_MAX_COUNT) {
|
|
1667
|
+
throw invalid(`draftShortcuts must hold at most ${DRAFT_SHORTCUT_MAX_COUNT} shortcuts.`);
|
|
1668
|
+
}
|
|
1669
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1670
|
+
return value.map((entry, index) => {
|
|
1671
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
|
1672
|
+
throw invalid(`draftShortcuts[${index}] must be an object.`);
|
|
1673
|
+
}
|
|
1674
|
+
const raw = entry;
|
|
1675
|
+
const id = shortcutField(raw, "id", index, DRAFT_SHORTCUT_ID_MAX);
|
|
1676
|
+
if (seen.has(id)) throw invalid(`draftShortcuts[${index}].id duplicates an earlier id.`);
|
|
1677
|
+
seen.add(id);
|
|
1678
|
+
return {
|
|
1679
|
+
id,
|
|
1680
|
+
title: shortcutField(raw, "title", index, DRAFT_SHORTCUT_TITLE_MAX),
|
|
1681
|
+
prompt: shortcutField(raw, "prompt", index, DRAFT_SHORTCUT_PROMPT_MAX)
|
|
1682
|
+
};
|
|
1683
|
+
});
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1576
1686
|
// src/http/routes/me.ts
|
|
1577
1687
|
function meRoutes(deps2) {
|
|
1578
1688
|
const app2 = new Hono5();
|
|
@@ -1623,6 +1733,9 @@ function meRoutes(deps2) {
|
|
|
1623
1733
|
});
|
|
1624
1734
|
app2.put("/prefs", async (c) => {
|
|
1625
1735
|
const body = await readJson(c);
|
|
1736
|
+
if (body.draftShortcuts !== void 0) {
|
|
1737
|
+
body.draftShortcuts = validateDraftShortcuts(body.draftShortcuts);
|
|
1738
|
+
}
|
|
1626
1739
|
const raw = deps2.prefsRepo.get(c.var.user.userId);
|
|
1627
1740
|
let current2 = {};
|
|
1628
1741
|
if (raw !== null) {
|
|
@@ -1815,8 +1928,8 @@ function isHttpUrl(value) {
|
|
|
1815
1928
|
return false;
|
|
1816
1929
|
}
|
|
1817
1930
|
}
|
|
1818
|
-
function probeUrl(baseUrl,
|
|
1819
|
-
return baseUrl.trim().replace(/\/+$/, "") +
|
|
1931
|
+
function probeUrl(baseUrl, path25) {
|
|
1932
|
+
return baseUrl.trim().replace(/\/+$/, "") + path25;
|
|
1820
1933
|
}
|
|
1821
1934
|
function isApiErrorShape(body) {
|
|
1822
1935
|
const err = body.error;
|
|
@@ -2133,6 +2246,21 @@ function modelsRoutes(deps2) {
|
|
|
2133
2246
|
}
|
|
2134
2247
|
return c.json(await deps2.projectConfigService.detectProtocol(projectId, req));
|
|
2135
2248
|
});
|
|
2249
|
+
app2.post("/list", async (c) => {
|
|
2250
|
+
const projectId = requireValidId(c, "projectId");
|
|
2251
|
+
deps2.projectService.requireProjectOwner(c.var.user.userId, projectId);
|
|
2252
|
+
const body = await readJson(c);
|
|
2253
|
+
const req = {
|
|
2254
|
+
baseUrl: requireString(body, "baseUrl", { minLen: 1, maxLen: 2e3 }),
|
|
2255
|
+
clientType: requireString(body, "clientType", { minLen: 1, maxLen: 64 })
|
|
2256
|
+
};
|
|
2257
|
+
if (!isHttpUrl(req.baseUrl)) throw badRequest("baseUrl must be an absolute http(s) URL.");
|
|
2258
|
+
if (body.apiKey !== void 0) {
|
|
2259
|
+
if (typeof body.apiKey !== "string") throw badRequest("apiKey must be a string.");
|
|
2260
|
+
if (body.apiKey) req.apiKey = body.apiKey;
|
|
2261
|
+
}
|
|
2262
|
+
return c.json(await deps2.projectConfigService.listEndpointModels(req));
|
|
2263
|
+
});
|
|
2136
2264
|
app2.post("/detect-vision", async (c) => {
|
|
2137
2265
|
const projectId = requireValidId(c, "projectId");
|
|
2138
2266
|
deps2.projectService.requireProjectOwner(c.var.user.userId, projectId);
|
|
@@ -3479,15 +3607,17 @@ function sessionsRoutes(deps2) {
|
|
|
3479
3607
|
const aborted = deps2.manager.abortTask(row.sessionId);
|
|
3480
3608
|
return c.body(null, aborted ? 202 : 204);
|
|
3481
3609
|
});
|
|
3482
|
-
app2.get("/:sessionId/processes", (c) => {
|
|
3610
|
+
app2.get("/:sessionId/processes", async (c) => {
|
|
3483
3611
|
const row = resolveSession(c);
|
|
3612
|
+
await deps2.manager.probeProcessServices(row.sessionId);
|
|
3484
3613
|
const processes = deps2.manager.listProcesses(row.sessionId).map((p) => ({
|
|
3485
3614
|
processId: p.processId,
|
|
3486
3615
|
pid: p.pid,
|
|
3487
3616
|
cmd: p.cmd,
|
|
3488
3617
|
cwd: p.cwd,
|
|
3489
3618
|
startedAt: new Date(p.startedAt).toISOString(),
|
|
3490
|
-
running: p.running
|
|
3619
|
+
running: p.running,
|
|
3620
|
+
...p.serviceUrl !== void 0 ? { serviceUrl: p.serviceUrl } : {}
|
|
3491
3621
|
}));
|
|
3492
3622
|
return c.json({ processes });
|
|
3493
3623
|
});
|
|
@@ -3676,8 +3806,98 @@ function chatDefaultsRoutes(deps2) {
|
|
|
3676
3806
|
return app2;
|
|
3677
3807
|
}
|
|
3678
3808
|
|
|
3679
|
-
// src/http/routes/
|
|
3809
|
+
// src/http/routes/command-policy.ts
|
|
3680
3810
|
import { Hono as Hono12 } from "hono";
|
|
3811
|
+
var MAX_RULES = 64;
|
|
3812
|
+
var MAX_RULE_NAME_LEN = 64;
|
|
3813
|
+
var MAX_RULE_PATTERN_LEN = 512;
|
|
3814
|
+
var MAX_RULE_DESCRIPTION_LEN = 300;
|
|
3815
|
+
function validateRules(value) {
|
|
3816
|
+
if (!Array.isArray(value)) {
|
|
3817
|
+
throw new HttpError(400, "invalid_rules", "rules must be an array.");
|
|
3818
|
+
}
|
|
3819
|
+
if (value.length > MAX_RULES) {
|
|
3820
|
+
throw new HttpError(400, "invalid_rules", `rules exceeds ${MAX_RULES} entries.`);
|
|
3821
|
+
}
|
|
3822
|
+
const rules = [];
|
|
3823
|
+
for (const entry of value) {
|
|
3824
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
|
3825
|
+
throw new HttpError(400, "invalid_rules", "Each rule must be an object.");
|
|
3826
|
+
}
|
|
3827
|
+
const { name, pattern, description, enabled } = entry;
|
|
3828
|
+
if (typeof name !== "string" || name.trim() === "" || name.length > MAX_RULE_NAME_LEN) {
|
|
3829
|
+
throw new HttpError(
|
|
3830
|
+
400,
|
|
3831
|
+
"invalid_rules",
|
|
3832
|
+
`Each rule needs a non-empty name of at most ${MAX_RULE_NAME_LEN} characters.`
|
|
3833
|
+
);
|
|
3834
|
+
}
|
|
3835
|
+
if (typeof pattern !== "string" || pattern === "" || pattern.length > MAX_RULE_PATTERN_LEN) {
|
|
3836
|
+
throw new HttpError(
|
|
3837
|
+
400,
|
|
3838
|
+
"invalid_rules",
|
|
3839
|
+
`Each rule needs a non-empty pattern of at most ${MAX_RULE_PATTERN_LEN} characters.`
|
|
3840
|
+
);
|
|
3841
|
+
}
|
|
3842
|
+
try {
|
|
3843
|
+
new RegExp(pattern);
|
|
3844
|
+
} catch {
|
|
3845
|
+
throw new HttpError(
|
|
3846
|
+
400,
|
|
3847
|
+
"invalid_rule_pattern",
|
|
3848
|
+
`Rule "${name.trim()}" has an invalid regular expression: ${pattern}`
|
|
3849
|
+
);
|
|
3850
|
+
}
|
|
3851
|
+
if (description !== void 0 && typeof description !== "string") {
|
|
3852
|
+
throw new HttpError(400, "invalid_rules", "A rule description must be a string.");
|
|
3853
|
+
}
|
|
3854
|
+
const desc = typeof description === "string" ? description.trim() : "";
|
|
3855
|
+
if (desc.length > MAX_RULE_DESCRIPTION_LEN) {
|
|
3856
|
+
throw new HttpError(
|
|
3857
|
+
400,
|
|
3858
|
+
"invalid_rules",
|
|
3859
|
+
`A rule description is at most ${MAX_RULE_DESCRIPTION_LEN} characters.`
|
|
3860
|
+
);
|
|
3861
|
+
}
|
|
3862
|
+
if (enabled !== void 0 && typeof enabled !== "boolean") {
|
|
3863
|
+
throw new HttpError(400, "invalid_rules", "A rule's enabled must be a boolean.");
|
|
3864
|
+
}
|
|
3865
|
+
rules.push({
|
|
3866
|
+
name: name.trim(),
|
|
3867
|
+
pattern,
|
|
3868
|
+
...desc !== "" ? { description: desc } : {},
|
|
3869
|
+
...enabled !== void 0 ? { enabled } : {}
|
|
3870
|
+
});
|
|
3871
|
+
}
|
|
3872
|
+
return rules;
|
|
3873
|
+
}
|
|
3874
|
+
function commandPolicyRoutes(deps2) {
|
|
3875
|
+
const app2 = new Hono12();
|
|
3876
|
+
app2.get("/", async (c) => {
|
|
3877
|
+
const projectId = requireValidId(c, "projectId");
|
|
3878
|
+
deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
|
3879
|
+
return c.json(await deps2.projectConfigService.getCommandPolicy(projectId));
|
|
3880
|
+
});
|
|
3881
|
+
app2.put("/", async (c) => {
|
|
3882
|
+
const projectId = requireValidId(c, "projectId");
|
|
3883
|
+
deps2.projectService.requireProjectOwner(c.var.user.userId, projectId);
|
|
3884
|
+
const body = await readJson(c);
|
|
3885
|
+
if (body.enabled !== void 0 && typeof body.enabled !== "boolean") {
|
|
3886
|
+
throw new HttpError(400, "invalid_enabled", "enabled must be a boolean.");
|
|
3887
|
+
}
|
|
3888
|
+
const rules = validateRules(body.rules);
|
|
3889
|
+
return c.json(
|
|
3890
|
+
await deps2.projectConfigService.setCommandPolicy(projectId, {
|
|
3891
|
+
...body.enabled !== void 0 ? { enabled: body.enabled } : {},
|
|
3892
|
+
rules
|
|
3893
|
+
})
|
|
3894
|
+
);
|
|
3895
|
+
});
|
|
3896
|
+
return app2;
|
|
3897
|
+
}
|
|
3898
|
+
|
|
3899
|
+
// src/http/routes/vault.ts
|
|
3900
|
+
import { Hono as Hono13 } from "hono";
|
|
3681
3901
|
function parseVaultUpdate(body) {
|
|
3682
3902
|
if (!Array.isArray(body.entries)) throw badRequest("entries must be an array.");
|
|
3683
3903
|
const entries = body.entries.map((item, i) => {
|
|
@@ -3699,7 +3919,7 @@ function parseVaultUpdate(body) {
|
|
|
3699
3919
|
return { entries };
|
|
3700
3920
|
}
|
|
3701
3921
|
function vaultRoutes(deps2) {
|
|
3702
|
-
const app2 = new
|
|
3922
|
+
const app2 = new Hono13();
|
|
3703
3923
|
app2.get("/", async (c) => {
|
|
3704
3924
|
const projectId = requireValidId(c, "projectId");
|
|
3705
3925
|
const agentId = requireValidId(c, "agentId");
|
|
@@ -3730,9 +3950,15 @@ function vaultRoutes(deps2) {
|
|
|
3730
3950
|
}
|
|
3731
3951
|
|
|
3732
3952
|
// src/http/routes/memory.ts
|
|
3733
|
-
import { Hono as
|
|
3953
|
+
import { Hono as Hono14 } from "hono";
|
|
3954
|
+
var IMPORT_MODES = ["skip", "overwrite", "replace"];
|
|
3955
|
+
function exportStamp(exportedAt) {
|
|
3956
|
+
const d = new Date(exportedAt);
|
|
3957
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
3958
|
+
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`;
|
|
3959
|
+
}
|
|
3734
3960
|
function memoryRoutes(deps2) {
|
|
3735
|
-
const app2 = new
|
|
3961
|
+
const app2 = new Hono14();
|
|
3736
3962
|
const scope = (c) => {
|
|
3737
3963
|
const projectId = requireValidId(c, "projectId");
|
|
3738
3964
|
const agentId = requireValidId(c, "agentId");
|
|
@@ -3758,6 +3984,31 @@ function memoryRoutes(deps2) {
|
|
|
3758
3984
|
const name = pathParam(c, "fileName");
|
|
3759
3985
|
return c.json(await deps2.memoryService.readFile(projectId, agentId, key, name));
|
|
3760
3986
|
});
|
|
3987
|
+
app2.get("/scopes/:scopeKey/export", async (c) => {
|
|
3988
|
+
const { projectId, agentId } = scope(c);
|
|
3989
|
+
const key = pathParam(c, "scopeKey");
|
|
3990
|
+
const doc = await deps2.memoryService.exportScope(projectId, agentId, key);
|
|
3991
|
+
return new Response(JSON.stringify(doc, null, 2), {
|
|
3992
|
+
headers: {
|
|
3993
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
3994
|
+
"Content-Disposition": `attachment; filename="${agentId}-${key}-memory-${exportStamp(doc.exportedAt)}.json"`
|
|
3995
|
+
}
|
|
3996
|
+
});
|
|
3997
|
+
});
|
|
3998
|
+
app2.post("/scopes/:scopeKey/import", async (c) => {
|
|
3999
|
+
const projectId = requireValidId(c, "projectId");
|
|
4000
|
+
const agentId = requireValidId(c, "agentId");
|
|
4001
|
+
deps2.projectService.requireProjectOwner(c.var.user.userId, projectId);
|
|
4002
|
+
const key = pathParam(c, "scopeKey");
|
|
4003
|
+
const body = await readJson(c);
|
|
4004
|
+
return c.json(
|
|
4005
|
+
await deps2.memoryService.importScope(projectId, agentId, key, {
|
|
4006
|
+
mode: optionalEnum(body, "mode", IMPORT_MODES) ?? "skip",
|
|
4007
|
+
confirm: optionalBoolean(body, "confirm") ?? false,
|
|
4008
|
+
payload: body.payload
|
|
4009
|
+
})
|
|
4010
|
+
);
|
|
4011
|
+
});
|
|
3761
4012
|
app2.delete("/scopes/:scopeKey/files/:fileName", async (c) => {
|
|
3762
4013
|
const { projectId, agentId } = scope(c);
|
|
3763
4014
|
const key = pathParam(c, "scopeKey");
|
|
@@ -3770,7 +4021,7 @@ function memoryRoutes(deps2) {
|
|
|
3770
4021
|
|
|
3771
4022
|
// src/http/routes/schedules.ts
|
|
3772
4023
|
import { createHash } from "crypto";
|
|
3773
|
-
import { Hono as
|
|
4024
|
+
import { Hono as Hono15 } from "hono";
|
|
3774
4025
|
import { isValidId as isValidId3 } from "@prismshadow/penguin-core";
|
|
3775
4026
|
|
|
3776
4027
|
// src/runtime/schedule-file.ts
|
|
@@ -3977,7 +4228,7 @@ var ScheduleFileCache = class {
|
|
|
3977
4228
|
async scan(projectId, agentId, force) {
|
|
3978
4229
|
const key = this.keyOf(projectId, agentId);
|
|
3979
4230
|
const dir = scheduleDir(this.root, projectId, agentId);
|
|
3980
|
-
const
|
|
4231
|
+
const cached2 = this.seen.get(key);
|
|
3981
4232
|
this.counters.gateStats += 1;
|
|
3982
4233
|
const dirMtime = await statMtime(dir);
|
|
3983
4234
|
if (dirMtime === null) {
|
|
@@ -3986,9 +4237,9 @@ var ScheduleFileCache = class {
|
|
|
3986
4237
|
}
|
|
3987
4238
|
const next = { mtimeMs: cacheable(dirMtime), files: /* @__PURE__ */ new Map() };
|
|
3988
4239
|
let names;
|
|
3989
|
-
if (!force &&
|
|
3990
|
-
next.mtimeMs =
|
|
3991
|
-
names = [...
|
|
4240
|
+
if (!force && cached2 !== void 0 && cached2.mtimeMs !== null && cached2.mtimeMs === dirMtime) {
|
|
4241
|
+
next.mtimeMs = cached2.mtimeMs;
|
|
4242
|
+
names = [...cached2.files.keys()];
|
|
3992
4243
|
} else {
|
|
3993
4244
|
this.counters.dirScans += 1;
|
|
3994
4245
|
let listing;
|
|
@@ -4002,7 +4253,7 @@ var ScheduleFileCache = class {
|
|
|
4002
4253
|
}
|
|
4003
4254
|
const entries = [];
|
|
4004
4255
|
for (const name of names) {
|
|
4005
|
-
const file = await this.revalidate(dir, name,
|
|
4256
|
+
const file = await this.revalidate(dir, name, cached2?.files.get(name));
|
|
4006
4257
|
if (file === null) continue;
|
|
4007
4258
|
next.files.set(name, file);
|
|
4008
4259
|
entries.push(file.entry);
|
|
@@ -4011,7 +4262,7 @@ var ScheduleFileCache = class {
|
|
|
4011
4262
|
return entries;
|
|
4012
4263
|
}
|
|
4013
4264
|
/** Reuses the cached parse while the file's stat still matches; re-reads otherwise. */
|
|
4014
|
-
async revalidate(dir, name,
|
|
4265
|
+
async revalidate(dir, name, cached2) {
|
|
4015
4266
|
const file = path4.join(dir, `${name}.toml`);
|
|
4016
4267
|
let stat;
|
|
4017
4268
|
try {
|
|
@@ -4019,7 +4270,7 @@ var ScheduleFileCache = class {
|
|
|
4019
4270
|
} catch {
|
|
4020
4271
|
return null;
|
|
4021
4272
|
}
|
|
4022
|
-
if (
|
|
4273
|
+
if (cached2 && cached2.mtimeMs === stat.mtimeMs && cached2.sizeBytes === stat.size) return cached2;
|
|
4023
4274
|
this.counters.fileReads += 1;
|
|
4024
4275
|
let raw;
|
|
4025
4276
|
try {
|
|
@@ -4152,17 +4403,17 @@ function requireScheduleName(raw) {
|
|
|
4152
4403
|
return raw;
|
|
4153
4404
|
}
|
|
4154
4405
|
function scheduleRoutes(deps2) {
|
|
4155
|
-
const app2 = new
|
|
4406
|
+
const app2 = new Hono15();
|
|
4156
4407
|
app2.get("/", async (c) => {
|
|
4157
4408
|
const projectId = requireValidId(c, "projectId");
|
|
4158
4409
|
const agentId = requireValidId(c, "agentId");
|
|
4159
4410
|
deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
|
4160
4411
|
await deps2.agentConfigService.requireExists(projectId, agentId);
|
|
4161
|
-
const { entries, invalid } = await deps2.scheduler.listAgent(projectId, agentId);
|
|
4412
|
+
const { entries, invalid: invalid2 } = await deps2.scheduler.listAgent(projectId, agentId);
|
|
4162
4413
|
const nowMs = Date.now();
|
|
4163
4414
|
const res = {
|
|
4164
4415
|
schedules: entries.map((e) => toItem(e.def, e.state, e.queued, nowMs)),
|
|
4165
|
-
invalidFiles:
|
|
4416
|
+
invalidFiles: invalid2
|
|
4166
4417
|
};
|
|
4167
4418
|
return c.json(res);
|
|
4168
4419
|
});
|
|
@@ -4242,16 +4493,16 @@ async function upsert(deps2, userId, projectId, agentId, name, body) {
|
|
|
4242
4493
|
await deps2.scheduler.reconcileAgent(projectId, agentId);
|
|
4243
4494
|
}
|
|
4244
4495
|
async function readItem(deps2, projectId, agentId, name) {
|
|
4245
|
-
const { entries, invalid } = await deps2.scheduler.listAgent(projectId, agentId);
|
|
4496
|
+
const { entries, invalid: invalid2 } = await deps2.scheduler.listAgent(projectId, agentId);
|
|
4246
4497
|
const entry = entries.find((e) => e.def.name === name);
|
|
4247
4498
|
if (entry) return toItem(entry.def, entry.state, entry.queued, Date.now());
|
|
4248
|
-
const bad =
|
|
4499
|
+
const bad = invalid2.find((i) => i.name === name);
|
|
4249
4500
|
if (bad) throw badRequest(`Invalid schedule file: ${bad.error}`);
|
|
4250
4501
|
throw new HttpError(404, "schedule_not_found", `Schedule does not exist: ${name}`);
|
|
4251
4502
|
}
|
|
4252
4503
|
|
|
4253
4504
|
// src/http/routes/benchmarks.ts
|
|
4254
|
-
import { Hono as
|
|
4505
|
+
import { Hono as Hono16 } from "hono";
|
|
4255
4506
|
var TEXT_PREVIEW_BYTES = 256 * 1024;
|
|
4256
4507
|
function listCaseFiles(deps2, material) {
|
|
4257
4508
|
return async (c) => {
|
|
@@ -4304,7 +4555,7 @@ function readCaseFile(deps2, material) {
|
|
|
4304
4555
|
};
|
|
4305
4556
|
}
|
|
4306
4557
|
function benchmarksRoutes(deps2) {
|
|
4307
|
-
const app2 = new
|
|
4558
|
+
const app2 = new Hono16();
|
|
4308
4559
|
app2.get("/", async (c) => {
|
|
4309
4560
|
const projectId = requireValidId(c, "projectId");
|
|
4310
4561
|
const agentId = requireValidId(c, "agentId");
|
|
@@ -4331,7 +4582,7 @@ function benchmarksRoutes(deps2) {
|
|
|
4331
4582
|
import fs7 from "fs/promises";
|
|
4332
4583
|
import path5 from "path";
|
|
4333
4584
|
import { unzipSync, strFromU8, zipSync } from "fflate";
|
|
4334
|
-
import { Hono as
|
|
4585
|
+
import { Hono as Hono17 } from "hono";
|
|
4335
4586
|
import {
|
|
4336
4587
|
installSkill,
|
|
4337
4588
|
listInstalledSkills,
|
|
@@ -4478,12 +4729,12 @@ function parseInstallNames(body) {
|
|
|
4478
4729
|
});
|
|
4479
4730
|
}
|
|
4480
4731
|
function skillLibraryRoutes() {
|
|
4481
|
-
const app2 = new
|
|
4732
|
+
const app2 = new Hono17();
|
|
4482
4733
|
app2.get("/", (c) => c.json(libraryResponse()));
|
|
4483
4734
|
return app2;
|
|
4484
4735
|
}
|
|
4485
4736
|
function agentSkillsRoutes(deps2) {
|
|
4486
|
-
const app2 = new
|
|
4737
|
+
const app2 = new Hono17();
|
|
4487
4738
|
const listResponse = async (projectId, agentId) => ({
|
|
4488
4739
|
skills: (await listInstalledSkills(deps2.config.root, projectId, agentId)).map(toMetadataItem)
|
|
4489
4740
|
});
|
|
@@ -4601,10 +4852,10 @@ function agentSkillsRoutes(deps2) {
|
|
|
4601
4852
|
|
|
4602
4853
|
// src/http/routes/agent-transfer.ts
|
|
4603
4854
|
import fs8 from "fs/promises";
|
|
4604
|
-
import { Hono as
|
|
4855
|
+
import { Hono as Hono18 } from "hono";
|
|
4605
4856
|
var MAX_ARCHIVE_BYTES2 = 14 * 1024 * 1024;
|
|
4606
4857
|
function agentTransferRoutes(deps2) {
|
|
4607
|
-
const app2 = new
|
|
4858
|
+
const app2 = new Hono18();
|
|
4608
4859
|
app2.get("/export", async (c) => {
|
|
4609
4860
|
const projectId = requireValidId(c, "projectId");
|
|
4610
4861
|
const agentId = requireValidId(c, "agentId");
|
|
@@ -4643,7 +4894,7 @@ function agentTransferRoutes(deps2) {
|
|
|
4643
4894
|
}
|
|
4644
4895
|
|
|
4645
4896
|
// src/http/routes/agents.ts
|
|
4646
|
-
import { Hono as
|
|
4897
|
+
import { Hono as Hono19 } from "hono";
|
|
4647
4898
|
|
|
4648
4899
|
// src/http/settle.ts
|
|
4649
4900
|
async function settleWithin(promises, ms) {
|
|
@@ -4657,7 +4908,7 @@ async function settleWithin(promises, ms) {
|
|
|
4657
4908
|
// src/http/routes/agents.ts
|
|
4658
4909
|
var ACTIVITY_DAYS = 30;
|
|
4659
4910
|
function agentsRoutes(deps2) {
|
|
4660
|
-
const app2 = new
|
|
4911
|
+
const app2 = new Hono19();
|
|
4661
4912
|
app2.get("/", async (c) => {
|
|
4662
4913
|
const projectId = requireValidId(c, "projectId");
|
|
4663
4914
|
deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
|
@@ -4724,9 +4975,9 @@ function agentsRoutes(deps2) {
|
|
|
4724
4975
|
import fs9 from "fs/promises";
|
|
4725
4976
|
import os from "os";
|
|
4726
4977
|
import path6 from "path";
|
|
4727
|
-
import { Hono as
|
|
4978
|
+
import { Hono as Hono20 } from "hono";
|
|
4728
4979
|
function dirsRoutes(deps2) {
|
|
4729
|
-
const app2 = new
|
|
4980
|
+
const app2 = new Hono20();
|
|
4730
4981
|
app2.get("/", async (c) => {
|
|
4731
4982
|
const projectId = requireValidId(c, "projectId");
|
|
4732
4983
|
deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
|
@@ -4770,11 +5021,11 @@ function dirsRoutes(deps2) {
|
|
|
4770
5021
|
import { mkdtemp, rm } from "fs/promises";
|
|
4771
5022
|
import { tmpdir } from "os";
|
|
4772
5023
|
import { join } from "path";
|
|
4773
|
-
import { Hono as
|
|
5024
|
+
import { Hono as Hono21 } from "hono";
|
|
4774
5025
|
import { McpToolProvider, resolveMCPServer } from "@prismshadow/penguin-core";
|
|
4775
5026
|
var MCP_TEST_TIMEOUT_CAP_MS = 3e4;
|
|
4776
5027
|
function agentConfigRoutes(deps2) {
|
|
4777
|
-
const app2 = new
|
|
5028
|
+
const app2 = new Hono21();
|
|
4778
5029
|
app2.get("/", async (c) => {
|
|
4779
5030
|
const projectId = requireValidId(c, "projectId");
|
|
4780
5031
|
const agentId = requireValidId(c, "agentId");
|
|
@@ -4877,7 +5128,7 @@ function agentConfigRoutes(deps2) {
|
|
|
4877
5128
|
}
|
|
4878
5129
|
|
|
4879
5130
|
// src/http/routes/agent-traces.ts
|
|
4880
|
-
import { Hono as
|
|
5131
|
+
import { Hono as Hono22 } from "hono";
|
|
4881
5132
|
var MAX_TRACE_BYTES = 14 * 1024 * 1024;
|
|
4882
5133
|
var SESSION_CATEGORIES2 = [
|
|
4883
5134
|
"active",
|
|
@@ -4886,7 +5137,7 @@ var SESSION_CATEGORIES2 = [
|
|
|
4886
5137
|
"archived"
|
|
4887
5138
|
];
|
|
4888
5139
|
function agentTracesRoutes(deps2) {
|
|
4889
|
-
const app2 = new
|
|
5140
|
+
const app2 = new Hono22();
|
|
4890
5141
|
app2.get("/", async (c) => {
|
|
4891
5142
|
const projectId = requireValidId(c, "projectId");
|
|
4892
5143
|
const agentId = requireValidId(c, "agentId");
|
|
@@ -4964,10 +5215,17 @@ function agentTracesRoutes(deps2) {
|
|
|
4964
5215
|
}
|
|
4965
5216
|
|
|
4966
5217
|
// src/http/routes/usage.ts
|
|
4967
|
-
import { Hono as
|
|
5218
|
+
import { Hono as Hono23 } from "hono";
|
|
4968
5219
|
var GROUP_BYS = ["date", "agent", "model", "session"];
|
|
5220
|
+
var GRANULARITIES = ["minute", "hour", "day", "week", "month"];
|
|
5221
|
+
function optionalTsParam(value, label) {
|
|
5222
|
+
if (value === void 0 || value === "") return void 0;
|
|
5223
|
+
const ms = Date.parse(value);
|
|
5224
|
+
if (Number.isNaN(ms)) throw badRequest(`${label} must be an ISO-8601 timestamp.`);
|
|
5225
|
+
return new Date(ms).toISOString();
|
|
5226
|
+
}
|
|
4969
5227
|
function usageRoutes(deps2) {
|
|
4970
|
-
const app2 = new
|
|
5228
|
+
const app2 = new Hono23();
|
|
4971
5229
|
app2.get("/", async (c) => {
|
|
4972
5230
|
const projectId = requireValidId(c, "projectId");
|
|
4973
5231
|
deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
|
@@ -4975,14 +5233,30 @@ function usageRoutes(deps2) {
|
|
|
4975
5233
|
if (!GROUP_BYS.includes(groupByRaw)) {
|
|
4976
5234
|
throw badRequest(`groupBy must be one of ${GROUP_BYS.join(" / ")}.`);
|
|
4977
5235
|
}
|
|
5236
|
+
const granularityRaw = c.req.query("granularity") ?? "day";
|
|
5237
|
+
if (!GRANULARITIES.includes(granularityRaw)) {
|
|
5238
|
+
throw badRequest(`granularity must be one of ${GRANULARITIES.join(" / ")}.`);
|
|
5239
|
+
}
|
|
5240
|
+
const granularity = granularityRaw;
|
|
4978
5241
|
const from = optionalDateParam(c.req.query("from"), "from");
|
|
4979
5242
|
const to = optionalDateParam(c.req.query("to"), "to");
|
|
5243
|
+
const fromTs = optionalTsParam(c.req.query("fromTs"), "fromTs");
|
|
5244
|
+
const toTs = optionalTsParam(c.req.query("toTs"), "toTs");
|
|
5245
|
+
if (fromTs === void 0 !== (toTs === void 0)) {
|
|
5246
|
+
throw badRequest("fromTs and toTs must be given together.");
|
|
5247
|
+
}
|
|
5248
|
+
if (fromTs !== void 0 && toTs !== void 0 && fromTs > toTs) {
|
|
5249
|
+
throw badRequest("fromTs must not be after toTs.");
|
|
5250
|
+
}
|
|
4980
5251
|
const agentId = c.req.query("agentId");
|
|
4981
5252
|
const provider = c.req.query("provider");
|
|
4982
5253
|
const modelId = c.req.query("modelId");
|
|
4983
5254
|
return c.json(
|
|
4984
5255
|
await deps2.usageService.query(projectId, {
|
|
4985
5256
|
groupBy: groupByRaw,
|
|
5257
|
+
granularity,
|
|
5258
|
+
...fromTs !== void 0 ? { fromTs } : {},
|
|
5259
|
+
...toTs !== void 0 ? { toTs } : {},
|
|
4986
5260
|
// Unattributed errors (login failures, process crashes, etc. with no Project
|
|
4987
5261
|
// context) are visible only to admins: requireProjectAccess only guarantees
|
|
4988
5262
|
// "is a member of this Project" — a regular member seeing another tenant's errors
|
|
@@ -5022,7 +5296,7 @@ function usageRoutes(deps2) {
|
|
|
5022
5296
|
// src/http/routes/version.ts
|
|
5023
5297
|
import { spawn } from "child_process";
|
|
5024
5298
|
import { BUILD_DATE, VERSION } from "@prismshadow/penguin-core";
|
|
5025
|
-
import { Hono as
|
|
5299
|
+
import { Hono as Hono24 } from "hono";
|
|
5026
5300
|
var OUTPUT_CAP = 64 * 1024;
|
|
5027
5301
|
var UPDATE_TIMEOUT_MS = 10 * 60 * 1e3;
|
|
5028
5302
|
var REFUSAL_MARKERS = [
|
|
@@ -5088,7 +5362,7 @@ function runSelfUpdate(cliEntry) {
|
|
|
5088
5362
|
});
|
|
5089
5363
|
}
|
|
5090
5364
|
function versionRoutes(deps2) {
|
|
5091
|
-
const app2 = new
|
|
5365
|
+
const app2 = new Hono24();
|
|
5092
5366
|
let inflightRun = null;
|
|
5093
5367
|
app2.get("/", (c) => {
|
|
5094
5368
|
return c.json({ version: VERSION, buildDate: BUILD_DATE });
|
|
@@ -5268,6 +5542,77 @@ function localDateMinusDays(date, days) {
|
|
|
5268
5542
|
d.setDate(d.getDate() - days);
|
|
5269
5543
|
return formatLocalDate(d);
|
|
5270
5544
|
}
|
|
5545
|
+
function parseLocalDate(date) {
|
|
5546
|
+
const [y, m, d] = date.split("-").map(Number);
|
|
5547
|
+
return new Date(y, m - 1, d);
|
|
5548
|
+
}
|
|
5549
|
+
function formatLocalHourKey(date) {
|
|
5550
|
+
const hour = date.getHours().toString().padStart(2, "0");
|
|
5551
|
+
return `${formatLocalDate(date)}T${hour}:00`;
|
|
5552
|
+
}
|
|
5553
|
+
function formatLocalMinuteKey(date) {
|
|
5554
|
+
const hour = date.getHours().toString().padStart(2, "0");
|
|
5555
|
+
const minute = date.getMinutes().toString().padStart(2, "0");
|
|
5556
|
+
return `${formatLocalDate(date)}T${hour}:${minute}`;
|
|
5557
|
+
}
|
|
5558
|
+
function enumerateTsBuckets(fromTs, toTs, granularity, cap = Infinity) {
|
|
5559
|
+
const keys = [];
|
|
5560
|
+
if (fromTs.getTime() > toTs.getTime()) return keys;
|
|
5561
|
+
const start = new Date(fromTs);
|
|
5562
|
+
start.setSeconds(0, 0);
|
|
5563
|
+
if (granularity === "hour") start.setMinutes(0);
|
|
5564
|
+
const stepMs = granularity === "hour" ? 36e5 : 6e4;
|
|
5565
|
+
const fmt = granularity === "hour" ? formatLocalHourKey : formatLocalMinuteKey;
|
|
5566
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5567
|
+
for (let t = start.getTime(); t <= toTs.getTime() && keys.length <= cap; t += stepMs) {
|
|
5568
|
+
const key = fmt(new Date(t));
|
|
5569
|
+
if (seen.has(key)) continue;
|
|
5570
|
+
seen.add(key);
|
|
5571
|
+
keys.push(key);
|
|
5572
|
+
}
|
|
5573
|
+
return keys;
|
|
5574
|
+
}
|
|
5575
|
+
function localWeekStart(date) {
|
|
5576
|
+
const d = parseLocalDate(date);
|
|
5577
|
+
d.setDate(d.getDate() - (d.getDay() + 6) % 7);
|
|
5578
|
+
return formatLocalDate(d);
|
|
5579
|
+
}
|
|
5580
|
+
function localMonthKey(date) {
|
|
5581
|
+
return date.slice(0, 7);
|
|
5582
|
+
}
|
|
5583
|
+
function enumerateBuckets(from, to, granularity, cap = Infinity) {
|
|
5584
|
+
const keys = [];
|
|
5585
|
+
if (from > to) return keys;
|
|
5586
|
+
const full = () => keys.length > cap;
|
|
5587
|
+
if (granularity === "hour") {
|
|
5588
|
+
const end = parseLocalDate(to);
|
|
5589
|
+
end.setHours(23);
|
|
5590
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5591
|
+
for (let t = parseLocalDate(from).getTime(); t <= end.getTime() && !full(); t += 36e5) {
|
|
5592
|
+
const key = formatLocalHourKey(new Date(t));
|
|
5593
|
+
if (seen.has(key)) continue;
|
|
5594
|
+
seen.add(key);
|
|
5595
|
+
keys.push(key);
|
|
5596
|
+
}
|
|
5597
|
+
return keys;
|
|
5598
|
+
}
|
|
5599
|
+
if (granularity === "month") {
|
|
5600
|
+
const end = localMonthKey(to);
|
|
5601
|
+
const d2 = parseLocalDate(`${localMonthKey(from)}-01`);
|
|
5602
|
+
for (; ; ) {
|
|
5603
|
+
const key = localMonthKey(formatLocalDate(d2));
|
|
5604
|
+
if (key > end || full()) return keys;
|
|
5605
|
+
keys.push(key);
|
|
5606
|
+
d2.setMonth(d2.getMonth() + 1);
|
|
5607
|
+
}
|
|
5608
|
+
}
|
|
5609
|
+
const stepDays = granularity === "week" ? 7 : 1;
|
|
5610
|
+
const d = parseLocalDate(granularity === "week" ? localWeekStart(from) : from);
|
|
5611
|
+
for (; formatLocalDate(d) <= to && !full(); d.setDate(d.getDate() + stepDays)) {
|
|
5612
|
+
keys.push(formatLocalDate(d));
|
|
5613
|
+
}
|
|
5614
|
+
return keys;
|
|
5615
|
+
}
|
|
5271
5616
|
|
|
5272
5617
|
// src/runtime/error-recorder.ts
|
|
5273
5618
|
var MESSAGE_MAX = 500;
|
|
@@ -5906,8 +6251,6 @@ function toUnrecoverableError(err) {
|
|
|
5906
6251
|
}
|
|
5907
6252
|
var ENTRY_IDLE_MS = 30 * 60 * 1e3;
|
|
5908
6253
|
var ENTRY_SWEEP_INTERVAL_MS = 60 * 1e3;
|
|
5909
|
-
var TITLE_EXCERPT_LIMIT = 4e3;
|
|
5910
|
-
var EARLY_TITLE_BODY_CHARS = 1e3;
|
|
5911
6254
|
function agentKey(projectId, agentId) {
|
|
5912
6255
|
return `${projectId}\0${agentId}`;
|
|
5913
6256
|
}
|
|
@@ -5953,12 +6296,6 @@ function isPlainText(role) {
|
|
|
5953
6296
|
return msg.type === "model_msg" && payload.type === "text" && payload.role === role && (!msg.origin || msg.origin.length === 0);
|
|
5954
6297
|
};
|
|
5955
6298
|
}
|
|
5956
|
-
function nestedAssistantText(msg) {
|
|
5957
|
-
const p = msg.payload;
|
|
5958
|
-
if (msg.type !== "model_msg" || p.type !== "text" || p.role !== "assistant") return null;
|
|
5959
|
-
if (!msg.origin || msg.origin.length === 0 || typeof p.text !== "string") return null;
|
|
5960
|
-
return { sessionId: msg.origin[msg.origin.length - 1], text: p.text };
|
|
5961
|
-
}
|
|
5962
6299
|
var SessionManager = class {
|
|
5963
6300
|
constructor(deps2) {
|
|
5964
6301
|
this.deps = deps2;
|
|
@@ -6058,6 +6395,38 @@ var SessionManager = class {
|
|
|
6058
6395
|
pendingSteering: [],
|
|
6059
6396
|
lastActivityMs: Date.now()
|
|
6060
6397
|
});
|
|
6398
|
+
this.registerNoticeListener(row.sessionId, session);
|
|
6399
|
+
}
|
|
6400
|
+
/**
|
|
6401
|
+
* Subscribes the background hooks on a runtime Session entering the active table — every
|
|
6402
|
+
* insertion path must call it (ensureEntry's loads, adopt's fresh creations):
|
|
6403
|
+
* - the idle-arrival signal for completion notices (mid-run arrivals are delivered inside
|
|
6404
|
+
* the run by core; this signal is the only trigger left when the session sits idle);
|
|
6405
|
+
* - live-forwarded background-subagent messages, published to the session channel (the
|
|
6406
|
+
* same feed SSE relays) and recorded for usage — a background child streams to the
|
|
6407
|
+
* frontend in real time past the launching turn's end, until its terminal state.
|
|
6408
|
+
*/
|
|
6409
|
+
registerNoticeListener(sessionId, session) {
|
|
6410
|
+
session.onBackgroundNotice?.(() => void this.startBackgroundNoticeTask(sessionId));
|
|
6411
|
+
session.onBackgroundMessage?.((msg) => this.forwardBackgroundMessage(sessionId, msg));
|
|
6412
|
+
}
|
|
6413
|
+
/** Publishes one live background-subagent message and records its usage (fire-and-forget; the child's own Trace is the durable record). */
|
|
6414
|
+
forwardBackgroundMessage(sessionId, msg) {
|
|
6415
|
+
const entry = this.entries.get(sessionId);
|
|
6416
|
+
if (!entry) return;
|
|
6417
|
+
entry.lastActivityMs = Date.now();
|
|
6418
|
+
this.deps.channels.get(entry.sessionId).publish(msg);
|
|
6419
|
+
const ctx = {
|
|
6420
|
+
projectId: entry.projectId,
|
|
6421
|
+
agentId: entry.agentId,
|
|
6422
|
+
sessionId: entry.sessionId,
|
|
6423
|
+
provider: entry.provider,
|
|
6424
|
+
modelId: entry.modelId
|
|
6425
|
+
};
|
|
6426
|
+
void this.deps.recorder.record(ctx, msg).catch((err) => {
|
|
6427
|
+
this.log(`[usage] Insert failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
6428
|
+
this.deps.errors?.record({ source: "usage", err, ctx, code: "usage_insert_failed" });
|
|
6429
|
+
});
|
|
6061
6430
|
}
|
|
6062
6431
|
/**
|
|
6063
6432
|
* After an Agent's vault is updated: bump the Agent's config generation so every
|
|
@@ -6498,6 +6867,15 @@ var SessionManager = class {
|
|
|
6498
6867
|
listProcesses(sessionId) {
|
|
6499
6868
|
return this.entries.get(sessionId)?.session.listBackgroundCommands?.() ?? [];
|
|
6500
6869
|
}
|
|
6870
|
+
/**
|
|
6871
|
+
* Refreshes the listen-port probes behind the process list's `serviceUrl` (core
|
|
6872
|
+
* `Session.probeBackgroundCommandServices`): called by the processes route before
|
|
6873
|
+
* listing, so the first fetch already carries probed URLs. Bounded by core's own probe
|
|
6874
|
+
* timeout and TTL; a session that isn't loaded has no processes to probe.
|
|
6875
|
+
*/
|
|
6876
|
+
async probeProcessServices(sessionId) {
|
|
6877
|
+
await this.entries.get(sessionId)?.session.probeBackgroundCommandServices?.();
|
|
6878
|
+
}
|
|
6501
6879
|
/** Kills one background command process of a loaded session; false when the session isn't loaded or the id is unknown. */
|
|
6502
6880
|
killProcess(sessionId, processId) {
|
|
6503
6881
|
const entry = this.entries.get(sessionId);
|
|
@@ -6642,6 +7020,8 @@ var SessionManager = class {
|
|
|
6642
7020
|
if (entry.status !== "idle" || entry.approvals.size !== 0 || entry.running !== null) continue;
|
|
6643
7021
|
if (entry.followUps.length > 0) continue;
|
|
6644
7022
|
if (entry.session.listBackgroundCommands?.().some((p) => p.running)) continue;
|
|
7023
|
+
if (entry.session.hasRunningBackgroundSubagents?.()) continue;
|
|
7024
|
+
if (entry.session.hasPendingBackgroundNotices?.()) continue;
|
|
6645
7025
|
if (now - entry.lastActivityMs <= idleMs) continue;
|
|
6646
7026
|
this.entries.delete(key);
|
|
6647
7027
|
}
|
|
@@ -6740,17 +7120,45 @@ var SessionManager = class {
|
|
|
6740
7120
|
lastActivityMs: Date.now()
|
|
6741
7121
|
};
|
|
6742
7122
|
this.entries.set(currentId, entry);
|
|
7123
|
+
this.registerNoticeListener(currentId, session);
|
|
6743
7124
|
return entry;
|
|
6744
7125
|
}
|
|
7126
|
+
/**
|
|
7127
|
+
* Auto-start of queued background completion notices: called when core signals an
|
|
7128
|
+
* idle-session arrival, and from drive's finally for notices that raced a run's exit.
|
|
7129
|
+
* Revalidates under the session lock; only an idle entry launches, and only when the
|
|
7130
|
+
* queue is non-empty — a busy entry skips, because the running/queued task's engine
|
|
7131
|
+
* drains the same queue at its own boundaries (each notice is delivered exactly once).
|
|
7132
|
+
*/
|
|
7133
|
+
async startBackgroundNoticeTask(sessionId) {
|
|
7134
|
+
try {
|
|
7135
|
+
await this.withLock(sessionId, async () => {
|
|
7136
|
+
if (this.closed || this.deletingSessions.has(sessionId)) return;
|
|
7137
|
+
const row = this.deps.sessions.findById(sessionId);
|
|
7138
|
+
if (row && this.deletingAgents.has(agentKey(row.projectId, row.agentId))) return;
|
|
7139
|
+
const entry = this.entries.get(sessionId);
|
|
7140
|
+
if (!entry) {
|
|
7141
|
+
this.log(`[background] notice for ${sessionId} dropped: runtime entry not loaded`);
|
|
7142
|
+
return;
|
|
7143
|
+
}
|
|
7144
|
+
if (entry.status !== "idle" || entry.followUps.length > 0) return;
|
|
7145
|
+
const input = entry.session.takeBackgroundNotices?.() ?? [];
|
|
7146
|
+
if (input.length === 0) return;
|
|
7147
|
+
this.launchTask(entry, input);
|
|
7148
|
+
});
|
|
7149
|
+
} catch (err) {
|
|
7150
|
+
this.log(
|
|
7151
|
+
`[background] notice task start failed: ${err instanceof Error ? err.message : String(err)}`
|
|
7152
|
+
);
|
|
7153
|
+
}
|
|
7154
|
+
}
|
|
6745
7155
|
/**
|
|
6746
7156
|
* Drive the output stream in the background: publish each message + persist usage +
|
|
6747
7157
|
* persist LLM/tool errors; on completion (including errors) resets to idle and pushes
|
|
6748
7158
|
* the status. `titleSource` is passed only for Task runs (compaction doesn't generate
|
|
6749
|
-
* a title):
|
|
7159
|
+
* a title): its user text drives automatic title generation at run start.
|
|
6750
7160
|
*/
|
|
6751
7161
|
async drive(entry, gen, titleSource) {
|
|
6752
|
-
let earlyTitleFired = false;
|
|
6753
|
-
let mainBodyChars = 0;
|
|
6754
7162
|
const ctx = {
|
|
6755
7163
|
projectId: entry.projectId,
|
|
6756
7164
|
agentId: entry.agentId,
|
|
@@ -6758,6 +7166,12 @@ var SessionManager = class {
|
|
|
6758
7166
|
provider: entry.provider,
|
|
6759
7167
|
modelId: entry.modelId
|
|
6760
7168
|
};
|
|
7169
|
+
if (titleSource?.userExcerpt.trim()) {
|
|
7170
|
+
this.deps.titles?.maybeGenerate(ctx, entry.session, {
|
|
7171
|
+
fallbackText: titleSource.userExcerpt,
|
|
7172
|
+
material: { userText: titleSource.userExcerpt, assistantText: "" }
|
|
7173
|
+
});
|
|
7174
|
+
}
|
|
6761
7175
|
this.touchRow(ctx, (repo, at) => repo.markDriven(ctx.sessionId, at));
|
|
6762
7176
|
const watcher = this.deps.errors ? new StreamErrorWatcher(this.deps.errors, {
|
|
6763
7177
|
projectId: entry.projectId,
|
|
@@ -6788,6 +7202,18 @@ var SessionManager = class {
|
|
|
6788
7202
|
child.prompt = subagentPrompts.get(pendingId) ?? "";
|
|
6789
7203
|
subagentPrompts.delete(pendingId);
|
|
6790
7204
|
}
|
|
7205
|
+
if (child.prompt.trim()) {
|
|
7206
|
+
this.deps.titles?.maybeGenerate(
|
|
7207
|
+
{ ...ctx, agentId: child.agentId, sessionId: child.sessionId },
|
|
7208
|
+
entry.session,
|
|
7209
|
+
{
|
|
7210
|
+
fallbackText: child.prompt,
|
|
7211
|
+
material: { userText: child.prompt, assistantText: "" },
|
|
7212
|
+
notifyOn: entry.sessionId
|
|
7213
|
+
// Notify the frontend via the parent Session's SSE channel
|
|
7214
|
+
}
|
|
7215
|
+
);
|
|
7216
|
+
}
|
|
6791
7217
|
}
|
|
6792
7218
|
} catch (err) {
|
|
6793
7219
|
this.log(
|
|
@@ -6800,24 +7226,6 @@ var SessionManager = class {
|
|
|
6800
7226
|
code: "subagent_register_failed"
|
|
6801
7227
|
});
|
|
6802
7228
|
}
|
|
6803
|
-
} else {
|
|
6804
|
-
const nested = nestedAssistantText(msg);
|
|
6805
|
-
const child = nested ? children.get(nested.sessionId) : void 0;
|
|
6806
|
-
if (nested && child && child.assistantExcerpt.length < TITLE_EXCERPT_LIMIT) {
|
|
6807
|
-
child.assistantExcerpt += (child.assistantExcerpt ? "\n" : "") + nested.text;
|
|
6808
|
-
}
|
|
6809
|
-
}
|
|
6810
|
-
if (!earlyTitleFired && titleSource?.userExcerpt.trim()) {
|
|
6811
|
-
const p = msg.payload;
|
|
6812
|
-
if ((!msg.origin || msg.origin.length === 0) && msg.type === "model_msg" && p.type === "text" && p.role === "assistant" && p.text) {
|
|
6813
|
-
mainBodyChars += p.text.length;
|
|
6814
|
-
if (mainBodyChars >= EARLY_TITLE_BODY_CHARS) {
|
|
6815
|
-
earlyTitleFired = true;
|
|
6816
|
-
this.deps.titles?.maybeGenerate(ctx, entry.session, {
|
|
6817
|
-
fallbackText: titleSource.userExcerpt
|
|
6818
|
-
});
|
|
6819
|
-
}
|
|
6820
|
-
}
|
|
6821
7229
|
}
|
|
6822
7230
|
if (!msg.origin || msg.origin.length === 0) {
|
|
6823
7231
|
const bt = msg.payload.type;
|
|
@@ -6855,41 +7263,19 @@ var SessionManager = class {
|
|
|
6855
7263
|
entry.lastActivityMs = Date.now();
|
|
6856
7264
|
this.touchRow(ctx, (repo, at) => repo.touchLastActive(ctx.sessionId, at));
|
|
6857
7265
|
this.publishState(entry, "idle");
|
|
6858
|
-
if (titleSource && titleSource.userExcerpt.trim()) {
|
|
6859
|
-
this.deps.titles?.maybeGenerate(ctx, entry.session, {
|
|
6860
|
-
fallbackText: titleSource.userExcerpt
|
|
6861
|
-
});
|
|
6862
|
-
}
|
|
6863
7266
|
if (entry.followUps.length > 0) void this.startQueuedFollowUp(entry.sessionId);
|
|
6864
|
-
|
|
6865
|
-
if (!child.prompt.trim()) continue;
|
|
6866
|
-
this.deps.titles?.maybeGenerate(
|
|
6867
|
-
// Bookkeeping: Session/Agent record the subagent (the title belongs to it),
|
|
6868
|
-
// but the model reference still uses ctx's **parent-Session** pair
|
|
6869
|
-
// (provider, modelId) — this one-shot request really does run on the parent
|
|
6870
|
-
// Session's bare LLM (a subagent may switch models via run_subagent's
|
|
6871
|
-
// model_id).
|
|
6872
|
-
{ ...ctx, agentId: child.agentId, sessionId: child.sessionId },
|
|
6873
|
-
entry.session,
|
|
6874
|
-
{
|
|
6875
|
-
fallbackText: child.prompt,
|
|
6876
|
-
material: { userText: child.prompt, assistantText: child.assistantExcerpt },
|
|
6877
|
-
notifyOn: entry.sessionId
|
|
6878
|
-
// Notify the frontend via the parent Session's SSE channel
|
|
6879
|
-
}
|
|
6880
|
-
);
|
|
6881
|
-
}
|
|
7267
|
+
else void this.startBackgroundNoticeTask(entry.sessionId);
|
|
6882
7268
|
}
|
|
6883
7269
|
}
|
|
6884
7270
|
/**
|
|
6885
7271
|
* Register a subagent: persisted only when the origin message is session_meta
|
|
6886
7272
|
* (agentId is derived from the agent_state path: `<…>/<agentId>/agent_state`).
|
|
6887
|
-
* **The title is left blank** —
|
|
6888
|
-
* from the
|
|
6889
|
-
*
|
|
6890
|
-
*
|
|
6891
|
-
*
|
|
6892
|
-
*
|
|
7273
|
+
* **The title is left blank here** — the registration site in drive generates it
|
|
7274
|
+
* right away from the spawning prompt (fallback first words + LLM replacement), so
|
|
7275
|
+
* it never waits for the subagent's own output. Idempotent (children dedup +
|
|
7276
|
+
* insertOrIgnore); a subagent has its own Trace, so it's visible in both the list
|
|
7277
|
+
* and the trace view. On successful registration, the entry is put into `children`
|
|
7278
|
+
* and returned; a duplicate session_meta returns null.
|
|
6893
7279
|
*/
|
|
6894
7280
|
registerChildSession(entry, msg, children) {
|
|
6895
7281
|
if (!isSessionMeta2(msg)) return null;
|
|
@@ -6929,9 +7315,7 @@ var SessionManager = class {
|
|
|
6929
7315
|
const child = {
|
|
6930
7316
|
sessionId: childSid,
|
|
6931
7317
|
agentId,
|
|
6932
|
-
|
|
6933
|
-
prompt: "",
|
|
6934
|
-
assistantExcerpt: ""
|
|
7318
|
+
prompt: ""
|
|
6935
7319
|
};
|
|
6936
7320
|
children.set(childSid, child);
|
|
6937
7321
|
return child;
|
|
@@ -7070,10 +7454,10 @@ var Scheduler = class {
|
|
|
7070
7454
|
const owner = project?.ownerUserId ?? null;
|
|
7071
7455
|
const files = await this.files.list(projectId, agentId);
|
|
7072
7456
|
const entries = [];
|
|
7073
|
-
const
|
|
7457
|
+
const invalid2 = [];
|
|
7074
7458
|
for (const file of files) {
|
|
7075
7459
|
if (!file.parsed.ok) {
|
|
7076
|
-
|
|
7460
|
+
invalid2.push({ name: file.name, error: file.parsed.error });
|
|
7077
7461
|
continue;
|
|
7078
7462
|
}
|
|
7079
7463
|
const refError = await validateScheduleModelRef(
|
|
@@ -7082,7 +7466,7 @@ var Scheduler = class {
|
|
|
7082
7466
|
file.parsed.def
|
|
7083
7467
|
);
|
|
7084
7468
|
if (refError !== null) {
|
|
7085
|
-
|
|
7469
|
+
invalid2.push({ name: file.name, error: refError });
|
|
7086
7470
|
continue;
|
|
7087
7471
|
}
|
|
7088
7472
|
const state = this.registerEntry(projectId, agentId, owner, file.parsed.def, file.raw);
|
|
@@ -7097,7 +7481,7 @@ var Scheduler = class {
|
|
|
7097
7481
|
agentId,
|
|
7098
7482
|
files.map((f) => f.name)
|
|
7099
7483
|
);
|
|
7100
|
-
return { entries, invalid };
|
|
7484
|
+
return { entries, invalid: invalid2 };
|
|
7101
7485
|
}
|
|
7102
7486
|
/** For routes: state cleanup after a task is deleted (the unlink moved the dir mtime, but invalidate for immediate effect anyway). */
|
|
7103
7487
|
dropEntry(projectId, agentId, name) {
|
|
@@ -7121,13 +7505,13 @@ var Scheduler = class {
|
|
|
7121
7505
|
* removed Agents also drop their schedule-file cache entries here.
|
|
7122
7506
|
*/
|
|
7123
7507
|
async listAgentIds(projectId) {
|
|
7124
|
-
const
|
|
7508
|
+
const cached2 = this.agentDirs.get(projectId);
|
|
7125
7509
|
const mtimeMs = await statMtime(agentsDir(this.deps.root, projectId));
|
|
7126
7510
|
if (mtimeMs === null) {
|
|
7127
7511
|
this.agentDirs.delete(projectId);
|
|
7128
7512
|
return [];
|
|
7129
7513
|
}
|
|
7130
|
-
if (
|
|
7514
|
+
if (cached2 && cached2.mtimeMs === mtimeMs) return cached2.ids;
|
|
7131
7515
|
let ids;
|
|
7132
7516
|
try {
|
|
7133
7517
|
const items = await fs11.readdir(agentsDir(this.deps.root, projectId), {
|
|
@@ -7138,7 +7522,7 @@ var Scheduler = class {
|
|
|
7138
7522
|
this.agentDirs.delete(projectId);
|
|
7139
7523
|
return [];
|
|
7140
7524
|
}
|
|
7141
|
-
for (const gone of
|
|
7525
|
+
for (const gone of cached2?.ids.filter((id) => !ids.includes(id)) ?? []) {
|
|
7142
7526
|
this.files.invalidate(projectId, gone);
|
|
7143
7527
|
}
|
|
7144
7528
|
this.agentDirs.set(projectId, { mtimeMs: cacheable(mtimeMs), ids });
|
|
@@ -7269,7 +7653,8 @@ var Scheduler = class {
|
|
|
7269
7653
|
const firedAt = new Date(this.now()).toISOString();
|
|
7270
7654
|
try {
|
|
7271
7655
|
await this.deps.runner.startTask(sessionId, [
|
|
7272
|
-
|
|
7656
|
+
// sender "server": in the Trace this user turn was injected by the server's scheduler, not typed by a human.
|
|
7657
|
+
userText2(scheduledMessage(def.name, firedAt, def.prompt), "server")
|
|
7273
7658
|
]);
|
|
7274
7659
|
} catch (err) {
|
|
7275
7660
|
this.deps.errors.record({
|
|
@@ -7323,6 +7708,8 @@ var Scheduler = class {
|
|
|
7323
7708
|
// src/runtime/title-generator.ts
|
|
7324
7709
|
import {
|
|
7325
7710
|
emptyTokenCounts,
|
|
7711
|
+
matchAttachedFileLine,
|
|
7712
|
+
matchAttachedImageLine,
|
|
7326
7713
|
sanitizeTitle,
|
|
7327
7714
|
stripConversationMarkers,
|
|
7328
7715
|
tokenUsage
|
|
@@ -7334,11 +7721,30 @@ var TitleGenerator = class {
|
|
|
7334
7721
|
}
|
|
7335
7722
|
deps;
|
|
7336
7723
|
inflight = /* @__PURE__ */ new Set();
|
|
7724
|
+
/**
|
|
7725
|
+
* sessionId → the fallback title this generator wrote and the LLM may still replace.
|
|
7726
|
+
* An entry is dropped once the LLM result lands or a differing stored title (a manual
|
|
7727
|
+
* rename, or a title from before a restart) is observed — from then on the title is
|
|
7728
|
+
* treated as final. In-memory on purpose: the sessions table stores no auto/manual
|
|
7729
|
+
* marker, and after a restart an existing title is simply left alone.
|
|
7730
|
+
*/
|
|
7731
|
+
pendingFallbacks = /* @__PURE__ */ new Map();
|
|
7337
7732
|
log;
|
|
7338
|
-
/**
|
|
7733
|
+
/**
|
|
7734
|
+
* Runs the title policy for one run start (fire-and-forget): persists the user-input
|
|
7735
|
+
* fallback if the row has no title yet, then generates the LLM replacement in the
|
|
7736
|
+
* background. No-ops when the stored title is anything other than NULL or the fallback
|
|
7737
|
+
* this generator wrote (manual renames and pre-existing titles are final), or when a
|
|
7738
|
+
* generation is already in flight.
|
|
7739
|
+
*/
|
|
7339
7740
|
maybeGenerate(ctx, session, req) {
|
|
7340
|
-
|
|
7341
|
-
|
|
7741
|
+
try {
|
|
7742
|
+
if (!this.prepare(ctx, req)) return;
|
|
7743
|
+
} catch (err) {
|
|
7744
|
+
this.log(`[title] Fallback failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
7745
|
+
this.deps.errors?.record({ source: "title", err, ctx, code: "title_fallback_failed" });
|
|
7746
|
+
return;
|
|
7747
|
+
}
|
|
7342
7748
|
if (this.inflight.has(ctx.sessionId)) return;
|
|
7343
7749
|
this.inflight.add(ctx.sessionId);
|
|
7344
7750
|
void this.generate(ctx, session, req).catch((err) => {
|
|
@@ -7348,6 +7754,24 @@ var TitleGenerator = class {
|
|
|
7348
7754
|
this.inflight.delete(ctx.sessionId);
|
|
7349
7755
|
});
|
|
7350
7756
|
}
|
|
7757
|
+
/** Synchronous half of the policy: decides whether the LLM should run, writing the immediate fallback title on the way. */
|
|
7758
|
+
prepare(ctx, req) {
|
|
7759
|
+
const row = this.deps.sessions.findById(ctx.sessionId);
|
|
7760
|
+
if (!row) return false;
|
|
7761
|
+
if (row.title !== null) {
|
|
7762
|
+
if (row.title !== this.pendingFallbacks.get(ctx.sessionId)) {
|
|
7763
|
+
this.pendingFallbacks.delete(ctx.sessionId);
|
|
7764
|
+
return false;
|
|
7765
|
+
}
|
|
7766
|
+
return true;
|
|
7767
|
+
}
|
|
7768
|
+
const fallback = fallbackTitle(req.fallbackText);
|
|
7769
|
+
if (fallback !== null) {
|
|
7770
|
+
this.pendingFallbacks.set(ctx.sessionId, fallback);
|
|
7771
|
+
this.persist(ctx, fallback, req.notifyOn);
|
|
7772
|
+
}
|
|
7773
|
+
return true;
|
|
7774
|
+
}
|
|
7351
7775
|
async generate(ctx, session, req) {
|
|
7352
7776
|
let title = null;
|
|
7353
7777
|
try {
|
|
@@ -7374,21 +7798,60 @@ var TitleGenerator = class {
|
|
|
7374
7798
|
this.log(`[title] Model request failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
7375
7799
|
this.deps.errors?.record({ source: "title", err, ctx, code: "title_llm_failed" });
|
|
7376
7800
|
}
|
|
7377
|
-
|
|
7378
|
-
if (finalTitle === null) return;
|
|
7801
|
+
if (title === null) return;
|
|
7379
7802
|
const latest = this.deps.sessions.findById(ctx.sessionId);
|
|
7380
|
-
if (!latest
|
|
7381
|
-
this.
|
|
7382
|
-
|
|
7383
|
-
|
|
7384
|
-
|
|
7385
|
-
);
|
|
7803
|
+
if (!latest) return;
|
|
7804
|
+
if (latest.title !== null && latest.title !== this.pendingFallbacks.get(ctx.sessionId)) {
|
|
7805
|
+
this.pendingFallbacks.delete(ctx.sessionId);
|
|
7806
|
+
return;
|
|
7807
|
+
}
|
|
7808
|
+
this.pendingFallbacks.delete(ctx.sessionId);
|
|
7809
|
+
this.persist(ctx, title, req.notifyOn);
|
|
7810
|
+
}
|
|
7811
|
+
/**
|
|
7812
|
+
* Writes sessions.title and pushes the `session_title` event — to the given Session
|
|
7813
|
+
* channel (the session's own unless overridden) for the tab watching the conversation,
|
|
7814
|
+
* and to the Project's user-level channels for every session list. The latter is the
|
|
7815
|
+
* delivery that survives the start-of-run timing: a first Task's title fires before the
|
|
7816
|
+
* new Session's own channel has any subscriber.
|
|
7817
|
+
*/
|
|
7818
|
+
persist(ctx, title, notifyOn) {
|
|
7819
|
+
this.deps.sessions.updateTitle(ctx.sessionId, title);
|
|
7820
|
+
const event = { type: "session_title", sessionId: ctx.sessionId, title };
|
|
7821
|
+
this.deps.channels.get(notifyOn ?? ctx.sessionId).publish(event, "server_event");
|
|
7822
|
+
this.deps.notifyProjectUsers?.(ctx.projectId, event);
|
|
7386
7823
|
}
|
|
7387
7824
|
};
|
|
7825
|
+
var FALLBACK_MAX_CHARS = 30;
|
|
7388
7826
|
function fallbackTitle(text) {
|
|
7389
|
-
const firstLine = stripConversationMarkers(text).split("\n").find((l) => l.trim().length > 0);
|
|
7827
|
+
const firstLine = stripConversationMarkers(text).split("\n").find((l) => l.trim().length > 0 && !isAttachmentLine(l.trim()));
|
|
7390
7828
|
if (!firstLine) return null;
|
|
7391
|
-
|
|
7829
|
+
const collapsed = firstLine.replace(/\s+/g, " ").trim();
|
|
7830
|
+
const lead = collapsed.replace(
|
|
7831
|
+
/^["'“”‘’「」『』《》〈〉【】()()。..!!??;;,,、::\s]+/,
|
|
7832
|
+
""
|
|
7833
|
+
);
|
|
7834
|
+
const cut = truncateAtWord(lead || collapsed);
|
|
7835
|
+
return sanitizeTitle(cut) ?? cut;
|
|
7836
|
+
}
|
|
7837
|
+
function isAttachmentLine(line) {
|
|
7838
|
+
return matchAttachedImageLine(line) !== null || matchAttachedFileLine(line) !== null;
|
|
7839
|
+
}
|
|
7840
|
+
function truncateAtWord(text) {
|
|
7841
|
+
if (text.length <= FALLBACK_MAX_CHARS) return text;
|
|
7842
|
+
const end = splitsSurrogatePair(text, FALLBACK_MAX_CHARS) ? FALLBACK_MAX_CHARS - 1 : FALLBACK_MAX_CHARS;
|
|
7843
|
+
const cut = text.slice(0, end);
|
|
7844
|
+
const wordChar = /[A-Za-z0-9'’_-]/;
|
|
7845
|
+
if (wordChar.test(text[end]) && wordChar.test(cut[cut.length - 1])) {
|
|
7846
|
+
const lastSpace = cut.lastIndexOf(" ");
|
|
7847
|
+
if (lastSpace > 0) return cut.slice(0, lastSpace).trimEnd();
|
|
7848
|
+
}
|
|
7849
|
+
return cut.trimEnd();
|
|
7850
|
+
}
|
|
7851
|
+
function splitsSurrogatePair(text, i) {
|
|
7852
|
+
const high = text.charCodeAt(i - 1);
|
|
7853
|
+
const low = text.charCodeAt(i);
|
|
7854
|
+
return high >= 55296 && high <= 56319 && low >= 56320 && low <= 57343;
|
|
7392
7855
|
}
|
|
7393
7856
|
|
|
7394
7857
|
// src/runtime/usage-recorder.ts
|
|
@@ -7473,9 +7936,11 @@ var AdminService = class {
|
|
|
7473
7936
|
constructor(deps2) {
|
|
7474
7937
|
this.deps = deps2;
|
|
7475
7938
|
this.now = deps2.now ?? (() => /* @__PURE__ */ new Date());
|
|
7939
|
+
this.hashCost = deps2.passwordHashCost ?? SCRYPT_COST;
|
|
7476
7940
|
}
|
|
7477
7941
|
deps;
|
|
7478
7942
|
now;
|
|
7943
|
+
hashCost;
|
|
7479
7944
|
listUsers() {
|
|
7480
7945
|
return this.deps.users.list().map(toUserInfo);
|
|
7481
7946
|
}
|
|
@@ -7495,7 +7960,7 @@ var AdminService = class {
|
|
|
7495
7960
|
}
|
|
7496
7961
|
const user = {
|
|
7497
7962
|
userId,
|
|
7498
|
-
passwordHash: await hashPassword(password),
|
|
7963
|
+
passwordHash: await hashPassword(password, this.hashCost),
|
|
7499
7964
|
isAdmin: false,
|
|
7500
7965
|
passwordIsInitial: true,
|
|
7501
7966
|
createdAt: this.now().toISOString()
|
|
@@ -7517,7 +7982,7 @@ var AdminService = class {
|
|
|
7517
7982
|
if (password.length < MIN_PASSWORD_LENGTH) {
|
|
7518
7983
|
throw new HttpError(400, "invalid_password", "Password must be at least 8 characters.");
|
|
7519
7984
|
}
|
|
7520
|
-
this.deps.users.updatePassword(userId, await hashPassword(password), true);
|
|
7985
|
+
this.deps.users.updatePassword(userId, await hashPassword(password, this.hashCost), true);
|
|
7521
7986
|
this.deps.authSessions.deleteByUser(userId);
|
|
7522
7987
|
this.deps.onPasswordChanged?.(userId);
|
|
7523
7988
|
}
|
|
@@ -7569,6 +8034,27 @@ var DesktopService = class {
|
|
|
7569
8034
|
this.shutdownHandler();
|
|
7570
8035
|
return true;
|
|
7571
8036
|
}
|
|
8037
|
+
// --- client-update relay ---------------------------------------------------
|
|
8038
|
+
updateStatus = null;
|
|
8039
|
+
updateCommandSender = null;
|
|
8040
|
+
/** Latest shell snapshot; null until the shell's first push lands. */
|
|
8041
|
+
getUpdateStatus() {
|
|
8042
|
+
return this.updateStatus;
|
|
8043
|
+
}
|
|
8044
|
+
/** index.ts stores each shell push here (already validated at the message port). */
|
|
8045
|
+
setUpdateStatus(status) {
|
|
8046
|
+
this.updateStatus = status;
|
|
8047
|
+
}
|
|
8048
|
+
/** index.ts registers the message-port sender; absent outside a shell-forked process. */
|
|
8049
|
+
onUpdateCommand(sender) {
|
|
8050
|
+
this.updateCommandSender = sender;
|
|
8051
|
+
}
|
|
8052
|
+
/** Invoked by the update routes; false when no shell port is wired (tests, plain runs). */
|
|
8053
|
+
requestUpdateCommand(action) {
|
|
8054
|
+
if (!this.updateCommandSender) return false;
|
|
8055
|
+
this.updateCommandSender(action);
|
|
8056
|
+
return true;
|
|
8057
|
+
}
|
|
7572
8058
|
};
|
|
7573
8059
|
|
|
7574
8060
|
// src/services/agent-config-service.ts
|
|
@@ -7610,8 +8096,12 @@ import { parse as parseToml2 } from "smol-toml";
|
|
|
7610
8096
|
import {
|
|
7611
8097
|
CHAT_APPROVAL_MODES,
|
|
7612
8098
|
DEFAULT_CHAT_THINKING_LEVELS as DEFAULT_CHAT_THINKING_LEVELS2,
|
|
8099
|
+
DEFAULT_COMMAND_POLICY_RULES,
|
|
8100
|
+
effectiveCommandPolicyRules,
|
|
8101
|
+
parseCommandPolicy,
|
|
7613
8102
|
GenerativeModel,
|
|
7614
8103
|
canonicalClientType,
|
|
8104
|
+
listEndpointModels as coreListEndpointModels,
|
|
7615
8105
|
catalogEntryFor,
|
|
7616
8106
|
defaultProjectConfig,
|
|
7617
8107
|
imageUrlMessage as imageUrlMessage2,
|
|
@@ -7621,6 +8111,7 @@ import {
|
|
|
7621
8111
|
resolveModelEnv as resolveModelEnv2,
|
|
7622
8112
|
userText as userText3
|
|
7623
8113
|
} from "@prismshadow/penguin-core";
|
|
8114
|
+
import { providerInfo } from "@prismshadow/penguin-core/model-catalog";
|
|
7624
8115
|
|
|
7625
8116
|
// src/services/vision-detect.ts
|
|
7626
8117
|
var VISION_PROBE_IMAGE = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
|
|
@@ -7667,6 +8158,17 @@ function maskApiKey(key) {
|
|
|
7667
8158
|
if (key.length <= 12) return "***";
|
|
7668
8159
|
return `${key.slice(0, 4)}\u2026${key.slice(-4)}`;
|
|
7669
8160
|
}
|
|
8161
|
+
function envFallbackFirstParty(entry) {
|
|
8162
|
+
const group = providerInfo(entry.provider);
|
|
8163
|
+
if (group === void 0 || group.id === "custom" || group.gatewayBaseUrl !== void 0) {
|
|
8164
|
+
return false;
|
|
8165
|
+
}
|
|
8166
|
+
const cat = catalogEntryFor(entry.provider, entry.modelId);
|
|
8167
|
+
if (cat !== void 0) {
|
|
8168
|
+
return canonicalClientType(entry.clientType) === canonicalClientType(cat.clientType) && entry.baseUrl === cat.baseUrl;
|
|
8169
|
+
}
|
|
8170
|
+
return entry.clientType === void 0 && entry.baseUrl === void 0;
|
|
8171
|
+
}
|
|
7670
8172
|
function asTable(v) {
|
|
7671
8173
|
return v !== null && typeof v === "object" && !Array.isArray(v) ? v : {};
|
|
7672
8174
|
}
|
|
@@ -7679,6 +8181,14 @@ function optNum(v) {
|
|
|
7679
8181
|
function optStr(v) {
|
|
7680
8182
|
return typeof v === "string" && v !== "" ? v : void 0;
|
|
7681
8183
|
}
|
|
8184
|
+
function toCommandPolicyRuleDtos(rules) {
|
|
8185
|
+
return rules.map((r) => ({
|
|
8186
|
+
name: r.name,
|
|
8187
|
+
pattern: r.pattern,
|
|
8188
|
+
...r.description !== void 0 ? { description: r.description } : {},
|
|
8189
|
+
enabled: r.enabled !== false
|
|
8190
|
+
}));
|
|
8191
|
+
}
|
|
7682
8192
|
function optRef(v) {
|
|
7683
8193
|
const t = asTable(v);
|
|
7684
8194
|
const provider = optStr(t.provider);
|
|
@@ -7695,6 +8205,7 @@ function showRef(provider, modelId) {
|
|
|
7695
8205
|
return `(provider=${provider}, model_id=${modelId})`;
|
|
7696
8206
|
}
|
|
7697
8207
|
var PROBE_PROMPT = 'ping - reply with the single word "pong" and nothing else. Do not think or explain.\n<think></think>';
|
|
8208
|
+
var LIST_MODELS_TIMEOUT_MS = 2e4;
|
|
7698
8209
|
var SPEED_PROBE_PROMPT = "Count from 1 to 50 as a comma-separated list, and nothing else. Do not think or explain.\n<think></think>";
|
|
7699
8210
|
var ProjectConfigService = class {
|
|
7700
8211
|
constructor(root) {
|
|
@@ -7745,8 +8256,8 @@ var ProjectConfigService = class {
|
|
|
7745
8256
|
this.cache.delete(projectId);
|
|
7746
8257
|
return null;
|
|
7747
8258
|
}
|
|
7748
|
-
const
|
|
7749
|
-
if (
|
|
8259
|
+
const cached2 = this.cache.get(projectId);
|
|
8260
|
+
if (cached2 && cached2.mtimeMs === mtimeMs) return cached2.table;
|
|
7750
8261
|
let raw;
|
|
7751
8262
|
try {
|
|
7752
8263
|
raw = await fs12.readFile(file, "utf8");
|
|
@@ -7795,6 +8306,9 @@ var ProjectConfigService = class {
|
|
|
7795
8306
|
await this.writeRaw(projectId, {
|
|
7796
8307
|
name,
|
|
7797
8308
|
...preset.default_model !== void 0 ? { default_model: preset.default_model } : {},
|
|
8309
|
+
// The factory command-policy rules are seeded exactly like the model presets:
|
|
8310
|
+
// copied in at creation, owned by the project from then on.
|
|
8311
|
+
...preset.command_policy !== void 0 ? { command_policy: preset.command_policy } : {},
|
|
7798
8312
|
models: preset.models
|
|
7799
8313
|
});
|
|
7800
8314
|
}
|
|
@@ -7902,6 +8416,44 @@ var ProjectConfigService = class {
|
|
|
7902
8416
|
await this.writeRaw(projectId, next);
|
|
7903
8417
|
return this.getChatDefaults(projectId);
|
|
7904
8418
|
}
|
|
8419
|
+
/**
|
|
8420
|
+
* Sandbox command policy (`[command_policy]`): read leniently, mirroring core's
|
|
8421
|
+
* loadProjectConfig tolerance — a non-boolean `enabled` reads as true (the default), a
|
|
8422
|
+
* rule missing name or pattern is dropped, and an absent (or non-array) `rules` value
|
|
8423
|
+
* serves the factory set: a project from before the block was seeded behaves as the
|
|
8424
|
+
* defaults until its first saved edit materializes them. The factory set also rides
|
|
8425
|
+
* along as `defaultRules` for the settings UI's "restore defaults".
|
|
8426
|
+
*/
|
|
8427
|
+
async getCommandPolicy(projectId) {
|
|
8428
|
+
const raw = await this.readRaw(projectId);
|
|
8429
|
+
const policy = parseCommandPolicy(raw.command_policy);
|
|
8430
|
+
return {
|
|
8431
|
+
enabled: policy?.enabled !== false,
|
|
8432
|
+
rules: toCommandPolicyRuleDtos(effectiveCommandPolicyRules(policy)),
|
|
8433
|
+
defaultRules: toCommandPolicyRuleDtos(DEFAULT_COMMAND_POLICY_RULES)
|
|
8434
|
+
};
|
|
8435
|
+
}
|
|
8436
|
+
/**
|
|
8437
|
+
* Replaces the `[command_policy]` block (declarative PUT, validated at the route). A PUT
|
|
8438
|
+
* always materializes the full rule list into the file — model-presets style: once
|
|
8439
|
+
* written, later factory changes never rewrite it — storing per field only what departs
|
|
8440
|
+
* from the defaults (`enabled = false`, a rule's `enabled = false`, a non-empty
|
|
8441
|
+
* description). Read-modify-write like setChatDefaults, preserving every other key.
|
|
8442
|
+
* Returns the block as re-read.
|
|
8443
|
+
*/
|
|
8444
|
+
async setCommandPolicy(projectId, req) {
|
|
8445
|
+
const raw = await this.readRaw(projectId);
|
|
8446
|
+
const block = {};
|
|
8447
|
+
if (req.enabled === false) block.enabled = false;
|
|
8448
|
+
block.rules = req.rules.map((r) => ({
|
|
8449
|
+
name: r.name,
|
|
8450
|
+
pattern: r.pattern,
|
|
8451
|
+
...r.description !== void 0 && r.description !== "" ? { description: r.description } : {},
|
|
8452
|
+
...r.enabled === false ? { enabled: false } : {}
|
|
8453
|
+
}));
|
|
8454
|
+
await this.writeRaw(projectId, { ...raw, command_policy: block });
|
|
8455
|
+
return this.getCommandPolicy(projectId);
|
|
8456
|
+
}
|
|
7905
8457
|
/** Pricing lookup for usage-recorder: the current pricing for this paired reference (undefined if none -> cost is NULL). */
|
|
7906
8458
|
async getPricing(projectId, provider, modelId) {
|
|
7907
8459
|
const raw = await this.readRaw(projectId);
|
|
@@ -7919,15 +8471,15 @@ var ProjectConfigService = class {
|
|
|
7919
8471
|
* Model connectivity test: the model reference `(provider, modelId)` is submitted
|
|
7920
8472
|
* as a pair in the request body; sends one minimal request using that model's
|
|
7921
8473
|
* config (optionally overridden with an unsaved apiKey / baseUrl) — no tools, no
|
|
7922
|
-
* system prompt, thinking
|
|
7923
|
-
* whether the endpoint answers. The model id sent to AgentHub is
|
|
7924
|
-
* itself (the upstream id verbatim; client_type inference follows it).
|
|
8474
|
+
* system prompt, thinking at the lowest level, a tiny output cap, 20s timeout —
|
|
8475
|
+
* just to see whether the endpoint answers. The model id sent to AgentHub is
|
|
8476
|
+
* `modelId` itself (the upstream id verbatim; client_type inference follows it).
|
|
7925
8477
|
*
|
|
7926
|
-
* A reasoning-heavy model
|
|
7927
|
-
*
|
|
7928
|
-
*
|
|
7929
|
-
*
|
|
7930
|
-
*
|
|
8478
|
+
* A reasoning-heavy model can spend the whole tiny output cap on thinking
|
|
8479
|
+
* (finish_reason=length with no text — AgentHub raises EmptyResponseError,
|
|
8480
|
+
* collapsed to a malformed outcome): the endpoint demonstrably streamed model
|
|
8481
|
+
* output, which is everything a connectivity test proves, so that case counts as
|
|
8482
|
+
* ok too (see probeVerdict).
|
|
7931
8483
|
*
|
|
7932
8484
|
* Never throws: the LLM layer collapses auth/parameter/network errors into an
|
|
7933
8485
|
* `LLMOutcome`, which is translated here into ok / message. Consumes very few
|
|
@@ -7960,7 +8512,9 @@ var ProjectConfigService = class {
|
|
|
7960
8512
|
...baseUrl ? { baseUrl } : {},
|
|
7961
8513
|
...clientType ? { clientType } : {},
|
|
7962
8514
|
tools: [],
|
|
7963
|
-
|
|
8515
|
+
// The lowest real level, not "none": several reasoning endpoints reject a request
|
|
8516
|
+
// that disables thinking outright, and a probe must not fail on the knob it sends.
|
|
8517
|
+
thinkingLevel: "low",
|
|
7964
8518
|
maxTokens: VISION_PROBE_MAX_TOKENS,
|
|
7965
8519
|
requestTimeoutMs: VISION_PROBE_TIMEOUT_MS
|
|
7966
8520
|
});
|
|
@@ -8005,7 +8559,9 @@ var ProjectConfigService = class {
|
|
|
8005
8559
|
...clientType ? { clientType } : {},
|
|
8006
8560
|
...fastMode ? { fastMode: true } : {},
|
|
8007
8561
|
tools: [],
|
|
8008
|
-
|
|
8562
|
+
// The lowest real level, not "none": several reasoning endpoints reject a request
|
|
8563
|
+
// that disables thinking outright, and a probe must not fail on the knob it sends.
|
|
8564
|
+
thinkingLevel: "low",
|
|
8009
8565
|
// Speed mode pairs a raised cap with a prompt that keeps generating (see
|
|
8010
8566
|
// SPEED_PROBE_PROMPT), so the stream lasts long enough for TTFT/TPS to describe
|
|
8011
8567
|
// decoding rather than one round trip; the plain connectivity test keeps the
|
|
@@ -8073,6 +8629,45 @@ var ProjectConfigService = class {
|
|
|
8073
8629
|
}
|
|
8074
8630
|
return detectModelProtocol({ baseUrl: req.baseUrl, ...apiKey ? { apiKey } : {} });
|
|
8075
8631
|
}
|
|
8632
|
+
/**
|
|
8633
|
+
* Endpoint model listing for the add-group import (see EndpointModelListRequest). All
|
|
8634
|
+
* parameters come from the request — a group being created has no stored entry to fall
|
|
8635
|
+
* back to; an omitted key follows the same environment chain as the connectivity test
|
|
8636
|
+
* (the wrapped SDK reads the protocol's own variable). Never throws: SDK construction
|
|
8637
|
+
* and request failures collapse into `{ ok:false, message }`, an AgentHub
|
|
8638
|
+
* UnsupportedOperationError additionally sets `unsupported` so the dialog can point at
|
|
8639
|
+
* the manual path, and a listing that outlives LIST_MODELS_TIMEOUT_MS is reported as
|
|
8640
|
+
* timed out. Nothing cancels the request behind it: the race only stops waiting, and a
|
|
8641
|
+
* later rejection is already handled by the race itself. The listing is returned
|
|
8642
|
+
* verbatim; dedup against the config is the caller's policy, exactly like the probe
|
|
8643
|
+
* routes never write anything either.
|
|
8644
|
+
*/
|
|
8645
|
+
async listEndpointModels(req, listImpl = coreListEndpointModels, timeoutMs = LIST_MODELS_TIMEOUT_MS) {
|
|
8646
|
+
const clientType = canonicalClientType(req.clientType) ?? req.clientType;
|
|
8647
|
+
let timer;
|
|
8648
|
+
try {
|
|
8649
|
+
const listing = listImpl({
|
|
8650
|
+
clientType,
|
|
8651
|
+
baseUrl: req.baseUrl,
|
|
8652
|
+
...req.apiKey ? { apiKey: req.apiKey } : {}
|
|
8653
|
+
});
|
|
8654
|
+
const timeout = new Promise((_, reject) => {
|
|
8655
|
+
timer = setTimeout(() => reject(new Error("model listing timed out")), timeoutMs);
|
|
8656
|
+
timer.unref?.();
|
|
8657
|
+
});
|
|
8658
|
+
const models = await Promise.race([listing, timeout]);
|
|
8659
|
+
return { ok: true, models };
|
|
8660
|
+
} catch (err) {
|
|
8661
|
+
const unsupported = err instanceof Error && err.name === "UnsupportedOperationError";
|
|
8662
|
+
return {
|
|
8663
|
+
ok: false,
|
|
8664
|
+
...unsupported ? { unsupported: true } : {},
|
|
8665
|
+
message: (err instanceof Error ? err.message : String(err)).slice(0, 300)
|
|
8666
|
+
};
|
|
8667
|
+
} finally {
|
|
8668
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
8669
|
+
}
|
|
8670
|
+
}
|
|
8076
8671
|
/**
|
|
8077
8672
|
* GET models view: masks credential (inline fields), flags the default Model;
|
|
8078
8673
|
* the group is the entry's `provider` field, looked up in the built-in catalog by
|
|
@@ -8105,6 +8700,8 @@ var ProjectConfigService = class {
|
|
|
8105
8700
|
const apiKey = optStr(m.api_key);
|
|
8106
8701
|
const credBaseUrl = optStr(m.base_url);
|
|
8107
8702
|
const createdAt = optStr(m.created_at);
|
|
8703
|
+
const envValue = envKey !== void 0 && envFallbackFirstParty({ provider, modelId, clientType, baseUrl: credBaseUrl }) ? process.env[envKey] ?? "" : "";
|
|
8704
|
+
const envKeyMasked = envValue !== "" ? maskApiKey(envValue) : void 0;
|
|
8108
8705
|
const info = {
|
|
8109
8706
|
provider,
|
|
8110
8707
|
modelId,
|
|
@@ -8116,6 +8713,7 @@ var ProjectConfigService = class {
|
|
|
8116
8713
|
...maxTokens !== void 0 ? { maxTokens } : {},
|
|
8117
8714
|
...fastMode !== void 0 ? { fastMode } : {},
|
|
8118
8715
|
...envKey ? { envKey } : {},
|
|
8716
|
+
...envKeyMasked !== void 0 ? { envKeyMasked } : {},
|
|
8119
8717
|
...pricingDto ? { pricing: pricingDto } : {},
|
|
8120
8718
|
...apiKey !== void 0 || credBaseUrl !== void 0 ? {
|
|
8121
8719
|
credential: {
|
|
@@ -8461,8 +9059,8 @@ var AgentConfigService = class {
|
|
|
8461
9059
|
const cfg = config2;
|
|
8462
9060
|
const yamlPath = systemConfigPath(this.root, projectId, agentId);
|
|
8463
9061
|
const doc = parseDocument(await fs13.readFile(yamlPath, "utf8"));
|
|
8464
|
-
const setIfProvided = (
|
|
8465
|
-
if (value !== void 0) doc.setIn(
|
|
9062
|
+
const setIfProvided = (path25, value) => {
|
|
9063
|
+
if (value !== void 0) doc.setIn(path25, value);
|
|
8466
9064
|
};
|
|
8467
9065
|
setIfProvided(["name"], optionalString(cfg, "name", { maxLen: 100, label: "name" }));
|
|
8468
9066
|
setIfProvided(
|
|
@@ -8659,6 +9257,7 @@ function validateMcpServers(value) {
|
|
|
8659
9257
|
}
|
|
8660
9258
|
|
|
8661
9259
|
// src/services/memory-service.ts
|
|
9260
|
+
import { randomBytes as randomBytes4 } from "crypto";
|
|
8662
9261
|
import fs14 from "fs/promises";
|
|
8663
9262
|
import path10 from "path";
|
|
8664
9263
|
import {
|
|
@@ -8672,9 +9271,76 @@ import {
|
|
|
8672
9271
|
readWorkspaceMarker
|
|
8673
9272
|
} from "@prismshadow/penguin-core";
|
|
8674
9273
|
var SCOPE_KEY_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9._-]*$/;
|
|
9274
|
+
var SCOPE_FORMAT = "penguin-memory-scope";
|
|
9275
|
+
var SCOPE_FORMAT_VERSION = 1;
|
|
9276
|
+
var MAX_IMPORT_FILES = 500;
|
|
9277
|
+
var MAX_IMPORT_FILE_BYTES = 512 * 1024;
|
|
9278
|
+
var MAX_IMPORT_TOTAL_BYTES = 8 * 1024 * 1024;
|
|
9279
|
+
var MAX_IMPORT_NAME_BYTES = 255;
|
|
9280
|
+
function indexLinkPattern(fileName) {
|
|
9281
|
+
const escaped = fileName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
9282
|
+
return new RegExp(`\\]\\(\\s*<?(?:\\./)?${escaped}>?\\s*(?:"[^"]*"\\s*)?\\)`);
|
|
9283
|
+
}
|
|
8675
9284
|
function isTopicFileName(name) {
|
|
8676
9285
|
return name.length > 0 && !name.startsWith(".") && !/[/\\]/.test(name) && name.toLowerCase().endsWith(".md") && name.toLowerCase() !== MEMORY_INDEX_FILENAME.toLowerCase();
|
|
8677
9286
|
}
|
|
9287
|
+
function parseTransferDocument(payload) {
|
|
9288
|
+
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
|
|
9289
|
+
throw badRequest("payload must be a Memory scope export object.");
|
|
9290
|
+
}
|
|
9291
|
+
const doc = payload;
|
|
9292
|
+
if (doc.format !== SCOPE_FORMAT) {
|
|
9293
|
+
throw badRequest(`payload.format must be "${SCOPE_FORMAT}".`);
|
|
9294
|
+
}
|
|
9295
|
+
if (doc.version !== SCOPE_FORMAT_VERSION) {
|
|
9296
|
+
throw badRequest(`payload.version must be ${SCOPE_FORMAT_VERSION}.`);
|
|
9297
|
+
}
|
|
9298
|
+
if (!Array.isArray(doc.files)) throw badRequest("payload.files must be an array.");
|
|
9299
|
+
if (doc.files.length > MAX_IMPORT_FILES) {
|
|
9300
|
+
throw badRequest(`payload.files must hold at most ${MAX_IMPORT_FILES} entries.`);
|
|
9301
|
+
}
|
|
9302
|
+
let total = 0;
|
|
9303
|
+
const measure = (text, label) => {
|
|
9304
|
+
if (text.includes("\0")) throw badRequest(`${label} must be text.`);
|
|
9305
|
+
const bytes = Buffer.byteLength(text, "utf8");
|
|
9306
|
+
if (bytes > MAX_IMPORT_FILE_BYTES) {
|
|
9307
|
+
throw badRequest(`${label} exceeds the ${MAX_IMPORT_FILE_BYTES / 1024}KB limit.`);
|
|
9308
|
+
}
|
|
9309
|
+
total += bytes;
|
|
9310
|
+
if (total > MAX_IMPORT_TOTAL_BYTES) {
|
|
9311
|
+
throw badRequest(
|
|
9312
|
+
`payload exceeds the ${MAX_IMPORT_TOTAL_BYTES / (1024 * 1024)}MB total limit.`
|
|
9313
|
+
);
|
|
9314
|
+
}
|
|
9315
|
+
};
|
|
9316
|
+
let index = null;
|
|
9317
|
+
if (doc.index !== null && doc.index !== void 0) {
|
|
9318
|
+
if (typeof doc.index !== "string") throw badRequest("payload.index must be a string or null.");
|
|
9319
|
+
measure(doc.index, "payload.index");
|
|
9320
|
+
index = doc.index;
|
|
9321
|
+
}
|
|
9322
|
+
const files = [];
|
|
9323
|
+
const seen = /* @__PURE__ */ new Set();
|
|
9324
|
+
for (const entry of doc.files) {
|
|
9325
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
|
9326
|
+
throw badRequest("Each payload.files entry must be an object.");
|
|
9327
|
+
}
|
|
9328
|
+
const { name, content } = entry;
|
|
9329
|
+
if (typeof name !== "string") throw badRequest("Each payload.files entry needs a name.");
|
|
9330
|
+
if (typeof content !== "string") {
|
|
9331
|
+
throw badRequest(`Content of ${name} must be a string.`);
|
|
9332
|
+
}
|
|
9333
|
+
if (name.includes("\0")) throw badRequest("A file name must be text.");
|
|
9334
|
+
if (Buffer.byteLength(name, "utf8") > MAX_IMPORT_NAME_BYTES) {
|
|
9335
|
+
throw badRequest(`A file name must be at most ${MAX_IMPORT_NAME_BYTES} bytes.`);
|
|
9336
|
+
}
|
|
9337
|
+
if (seen.has(name)) throw badRequest(`payload.files names ${name} twice.`);
|
|
9338
|
+
seen.add(name);
|
|
9339
|
+
measure(content, `Content of ${name}`);
|
|
9340
|
+
files.push({ name, content });
|
|
9341
|
+
}
|
|
9342
|
+
return { index, files };
|
|
9343
|
+
}
|
|
8678
9344
|
var MemoryService = class {
|
|
8679
9345
|
constructor(root, agentConfigService) {
|
|
8680
9346
|
this.root = root;
|
|
@@ -8748,9 +9414,22 @@ var MemoryService = class {
|
|
|
8748
9414
|
kind,
|
|
8749
9415
|
...workspacePath !== void 0 ? { workspacePath } : {},
|
|
8750
9416
|
fileCount: files.length,
|
|
9417
|
+
hasIndex: await this.hasIndexFile(dir),
|
|
8751
9418
|
...latest > 0 ? { updatedAt: new Date(latest).toISOString() } : {}
|
|
8752
9419
|
};
|
|
8753
9420
|
}
|
|
9421
|
+
/**
|
|
9422
|
+
* Whether the scope holds its own `MEMORY.md`. lstat, not stat: a symlink planted at that name
|
|
9423
|
+
* is not an index — treating it as one would carry a read or a write outside the Memory
|
|
9424
|
+
* directory, and the atomic write below replaces such a link instead of following it.
|
|
9425
|
+
*/
|
|
9426
|
+
async hasIndexFile(dir) {
|
|
9427
|
+
try {
|
|
9428
|
+
return (await fs14.lstat(path10.join(dir, MEMORY_INDEX_FILENAME))).isFile();
|
|
9429
|
+
} catch {
|
|
9430
|
+
return false;
|
|
9431
|
+
}
|
|
9432
|
+
}
|
|
8754
9433
|
/** Topic files of one scope: regular Markdown files only — the index, the `.workspace` marker, stray directories and symlinks (dirents that are not regular files) all stay out. */
|
|
8755
9434
|
async topicFileNames(dir) {
|
|
8756
9435
|
try {
|
|
@@ -8785,6 +9464,146 @@ var MemoryService = class {
|
|
|
8785
9464
|
throw new HttpError(404, "memory_file_not_found", `Memory file not found: ${fileName}`);
|
|
8786
9465
|
}
|
|
8787
9466
|
}
|
|
9467
|
+
/**
|
|
9468
|
+
* The whole scope as one transfer document: every topic file plus the scope's own `MEMORY.md`.
|
|
9469
|
+
*
|
|
9470
|
+
* JSON rather than an archive, because the payload is entirely UTF-8 Markdown and the import
|
|
9471
|
+
* side of it is an untrusted write into the Agent's Memory directory: a JSON document carries
|
|
9472
|
+
* no paths, no permissions and no compression, so its entries can go through the same per-file
|
|
9473
|
+
* name check every other write here uses, with nothing to unpack first.
|
|
9474
|
+
*
|
|
9475
|
+
* Unreadable entries are left out rather than failing the export, as elsewhere in this service.
|
|
9476
|
+
* The size bounds below apply to import only — an export reports what the directory actually
|
|
9477
|
+
* holds, and a scope grown past them is exactly the thing its owner needs to see.
|
|
9478
|
+
*/
|
|
9479
|
+
async exportScope(projectId, agentId, scopeKey) {
|
|
9480
|
+
const dir = await this.requireScopeDir(projectId, agentId, scopeKey);
|
|
9481
|
+
const files = [];
|
|
9482
|
+
for (const name of await this.topicFileNames(dir)) {
|
|
9483
|
+
const content = await this.readTextFile(path10.join(dir, name));
|
|
9484
|
+
if (content !== null) files.push({ name, content });
|
|
9485
|
+
}
|
|
9486
|
+
const kind = scopeKey === USER_SCOPE_KEY ? "user" : "workspace";
|
|
9487
|
+
const workspacePath = kind === "workspace" ? await readWorkspaceMarker(dir) : void 0;
|
|
9488
|
+
return {
|
|
9489
|
+
format: SCOPE_FORMAT,
|
|
9490
|
+
version: SCOPE_FORMAT_VERSION,
|
|
9491
|
+
scopeKey,
|
|
9492
|
+
kind,
|
|
9493
|
+
...workspacePath !== void 0 ? { workspacePath } : {},
|
|
9494
|
+
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
9495
|
+
index: await this.hasIndexFile(dir) ? await this.readTextFile(path10.join(dir, MEMORY_INDEX_FILENAME)) : null,
|
|
9496
|
+
files
|
|
9497
|
+
};
|
|
9498
|
+
}
|
|
9499
|
+
/**
|
|
9500
|
+
* Writes a transfer document into one scope.
|
|
9501
|
+
*
|
|
9502
|
+
* The default mode keeps every file already on disk and writes only names the scope does not
|
|
9503
|
+
* have, so an import can never cost the user a memory they did not agree to lose. `overwrite`
|
|
9504
|
+
* replaces a same-named file's content and `replace` additionally deletes what the document
|
|
9505
|
+
* does not carry; both are refused with 409 unless `confirm` is set — but only when they would
|
|
9506
|
+
* actually destroy something, since a confirmation that names nothing is just a click.
|
|
9507
|
+
*
|
|
9508
|
+
* The index follows the files. A scope with no index takes the document's; `replace` takes it
|
|
9509
|
+
* too (the whole scope is being replaced); a merge keeps the index that is there and appends
|
|
9510
|
+
* the document's lines for the files this import added, because only the indexes enter the
|
|
9511
|
+
* model's context and an unindexed memory is one the Agent never reads.
|
|
9512
|
+
*/
|
|
9513
|
+
async importScope(projectId, agentId, scopeKey, request) {
|
|
9514
|
+
const dir = await this.requireScopeDir(projectId, agentId, scopeKey);
|
|
9515
|
+
const { index, files } = parseTransferDocument(request.payload);
|
|
9516
|
+
const planned = files.map((f) => ({ ...f, target: this.resolveFile(dir, f.name) }));
|
|
9517
|
+
const present = new Set(await this.topicFileNames(dir));
|
|
9518
|
+
const carried = new Set(files.map((f) => f.name));
|
|
9519
|
+
const collides = files.filter((f) => present.has(f.name)).map((f) => f.name);
|
|
9520
|
+
const removed = request.mode === "replace" ? [...present].filter((name) => !carried.has(name)) : [];
|
|
9521
|
+
const overwritten = request.mode === "skip" ? [] : collides;
|
|
9522
|
+
const skipped = request.mode === "skip" ? collides : [];
|
|
9523
|
+
const replacesIndex = request.mode === "replace" && index !== null && await this.hasIndexFile(dir);
|
|
9524
|
+
if (!request.confirm && (overwritten.length > 0 || removed.length > 0 || replacesIndex)) {
|
|
9525
|
+
throw new HttpError(
|
|
9526
|
+
409,
|
|
9527
|
+
"memory_import_confirm_required",
|
|
9528
|
+
`This import would overwrite ${overwritten.length} and delete ${removed.length} memories in ${scopeKey}${replacesIndex ? `, and replace its ${MEMORY_INDEX_FILENAME}` : ""}. Repeat it with confirm set.`
|
|
9529
|
+
);
|
|
9530
|
+
}
|
|
9531
|
+
const added = [];
|
|
9532
|
+
for (const file of planned) {
|
|
9533
|
+
if (request.mode === "skip" && present.has(file.name)) continue;
|
|
9534
|
+
await this.writeScopeFile(dir, file.target, file.content);
|
|
9535
|
+
if (!present.has(file.name)) added.push(file.name);
|
|
9536
|
+
}
|
|
9537
|
+
for (const name of removed) {
|
|
9538
|
+
await fs14.unlink(path10.join(dir, name)).catch(() => {
|
|
9539
|
+
});
|
|
9540
|
+
}
|
|
9541
|
+
const written = [...added, ...overwritten];
|
|
9542
|
+
let indexWritten = false;
|
|
9543
|
+
if (index !== null) {
|
|
9544
|
+
if (replacesIndex || !await this.hasIndexFile(dir)) {
|
|
9545
|
+
await this.writeScopeFile(dir, path10.join(dir, MEMORY_INDEX_FILENAME), index);
|
|
9546
|
+
indexWritten = true;
|
|
9547
|
+
} else {
|
|
9548
|
+
indexWritten = await this.extendIndex(dir, index, written);
|
|
9549
|
+
}
|
|
9550
|
+
}
|
|
9551
|
+
for (const name of removed) await this.pruneIndexLines(dir, name);
|
|
9552
|
+
return { scopeKey, mode: request.mode, added, overwritten, skipped, removed, indexWritten };
|
|
9553
|
+
}
|
|
9554
|
+
/**
|
|
9555
|
+
* Appends the document's index lines for the files this import wrote: a line linking a written
|
|
9556
|
+
* file that the scope's index does not already link is added at the end, in the document's own
|
|
9557
|
+
* order. Nothing else is touched — the mirror image of pruneIndexLines, so between them the
|
|
9558
|
+
* index lists neither a file that is gone nor omits one that arrived.
|
|
9559
|
+
*/
|
|
9560
|
+
async extendIndex(dir, docIndex, written) {
|
|
9561
|
+
const indexPath = path10.join(dir, MEMORY_INDEX_FILENAME);
|
|
9562
|
+
const current2 = await this.readTextFile(indexPath);
|
|
9563
|
+
if (current2 === null) return false;
|
|
9564
|
+
const docLines = docIndex.split("\n");
|
|
9565
|
+
const additions = [];
|
|
9566
|
+
for (const name of written) {
|
|
9567
|
+
const link = indexLinkPattern(name);
|
|
9568
|
+
if (link.test(current2)) continue;
|
|
9569
|
+
const line = docLines.find((l) => link.test(l));
|
|
9570
|
+
if (line !== void 0 && !additions.includes(line)) additions.push(line);
|
|
9571
|
+
}
|
|
9572
|
+
if (additions.length === 0) return false;
|
|
9573
|
+
const head = current2 === "" || current2.endsWith("\n") ? current2 : `${current2}
|
|
9574
|
+
`;
|
|
9575
|
+
await this.writeScopeFile(dir, indexPath, `${head}${additions.join("\n")}
|
|
9576
|
+
`);
|
|
9577
|
+
return true;
|
|
9578
|
+
}
|
|
9579
|
+
/**
|
|
9580
|
+
* The write path for imported content, shared by the topic files and the index.
|
|
9581
|
+
*
|
|
9582
|
+
* The target has already been through `resolveFile` (or is the reserved index name, which is
|
|
9583
|
+
* never client-supplied), so it names a file directly inside this scope. The write goes to a
|
|
9584
|
+
* temporary dotfile — invisible to every topic listing — and is renamed over the target, so a
|
|
9585
|
+
* symlink standing at that name is replaced rather than followed out of the Memory directory,
|
|
9586
|
+
* and a reader never sees a half-written memory.
|
|
9587
|
+
*/
|
|
9588
|
+
async writeScopeFile(dir, target, content) {
|
|
9589
|
+
const tmp = path10.join(dir, `.import-${randomBytes4(6).toString("hex")}.tmp`);
|
|
9590
|
+
try {
|
|
9591
|
+
await fs14.writeFile(tmp, content, "utf8");
|
|
9592
|
+
await fs14.rename(tmp, target);
|
|
9593
|
+
} catch (err) {
|
|
9594
|
+
await fs14.rm(tmp, { force: true });
|
|
9595
|
+
throw err;
|
|
9596
|
+
}
|
|
9597
|
+
}
|
|
9598
|
+
/** Reads a regular file as text, null when it is absent, unreadable, or not a regular file (lstat, so a symlink is never followed). */
|
|
9599
|
+
async readTextFile(target) {
|
|
9600
|
+
try {
|
|
9601
|
+
if (!(await fs14.lstat(target)).isFile()) return null;
|
|
9602
|
+
return await fs14.readFile(target, "utf8");
|
|
9603
|
+
} catch {
|
|
9604
|
+
return null;
|
|
9605
|
+
}
|
|
9606
|
+
}
|
|
8788
9607
|
/**
|
|
8789
9608
|
* Deletes a topic file and mechanically drops its lines from the scope's `MEMORY.md`: any
|
|
8790
9609
|
* line whose Markdown link target is exactly this file (`](<file>)`) goes; a mention of the
|
|
@@ -8810,8 +9629,7 @@ var MemoryService = class {
|
|
|
8810
9629
|
} catch {
|
|
8811
9630
|
return;
|
|
8812
9631
|
}
|
|
8813
|
-
const
|
|
8814
|
-
const link = new RegExp(`\\]\\(\\s*<?(?:\\./)?${escaped}>?\\s*(?:"[^"]*"\\s*)?\\)`);
|
|
9632
|
+
const link = indexLinkPattern(fileName);
|
|
8815
9633
|
const lines = content.split("\n");
|
|
8816
9634
|
const kept = lines.filter((line) => !link.test(line));
|
|
8817
9635
|
if (kept.length === lines.length) return;
|
|
@@ -9389,7 +10207,7 @@ var BenchmarkService = class {
|
|
|
9389
10207
|
};
|
|
9390
10208
|
|
|
9391
10209
|
// src/services/snapshot-service.ts
|
|
9392
|
-
import { randomBytes as
|
|
10210
|
+
import { randomBytes as randomBytes5 } from "crypto";
|
|
9393
10211
|
import fs17 from "fs/promises";
|
|
9394
10212
|
import path13 from "path";
|
|
9395
10213
|
import * as tar from "tar";
|
|
@@ -9435,7 +10253,7 @@ var SnapshotService = class {
|
|
|
9435
10253
|
} catch {
|
|
9436
10254
|
}
|
|
9437
10255
|
await fs17.mkdir(dir, { recursive: true });
|
|
9438
|
-
const tmp = `${file}.tmp-${
|
|
10256
|
+
const tmp = `${file}.tmp-${randomBytes5(4).toString("hex")}`;
|
|
9439
10257
|
await tar.create(
|
|
9440
10258
|
{
|
|
9441
10259
|
gzip: true,
|
|
@@ -9463,7 +10281,7 @@ var SnapshotService = class {
|
|
|
9463
10281
|
async importArchive(projectId, agentId, archive, confirm) {
|
|
9464
10282
|
const current2 = await this.currentVersion(projectId, agentId);
|
|
9465
10283
|
const base = agentDir2(this.root, projectId, agentId);
|
|
9466
|
-
const staging = path13.join(base, `.import-${
|
|
10284
|
+
const staging = path13.join(base, `.import-${randomBytes5(6).toString("hex")}`);
|
|
9467
10285
|
await fs17.mkdir(staging, { recursive: true });
|
|
9468
10286
|
try {
|
|
9469
10287
|
const archiveFile = path13.join(staging, "archive.tar.gz");
|
|
@@ -9533,9 +10351,9 @@ var SnapshotService = class {
|
|
|
9533
10351
|
import fs18 from "fs/promises";
|
|
9534
10352
|
import { DEFAULT_PROJECT_ID, projectDir, provisionProjectAgents } from "@prismshadow/penguin-core";
|
|
9535
10353
|
var ABORT_SETTLE_TIMEOUT_MS = 5e3;
|
|
9536
|
-
async function dirExists(
|
|
10354
|
+
async function dirExists(path25) {
|
|
9537
10355
|
try {
|
|
9538
|
-
const stat = await fs18.stat(
|
|
10356
|
+
const stat = await fs18.stat(path25);
|
|
9539
10357
|
return stat.isDirectory();
|
|
9540
10358
|
} catch {
|
|
9541
10359
|
return false;
|
|
@@ -9958,7 +10776,7 @@ var TraceIndexService = class {
|
|
|
9958
10776
|
async doReconcile(projectId, agentId, force) {
|
|
9959
10777
|
const key = `${projectId}\0${agentId}`;
|
|
9960
10778
|
const dir = tracesDir2(this.root, projectId, agentId);
|
|
9961
|
-
const
|
|
10779
|
+
const cached2 = this.seen.get(key);
|
|
9962
10780
|
this.counters.gateStats += 1;
|
|
9963
10781
|
const rootMtime = await statMtime(dir);
|
|
9964
10782
|
if (rootMtime === null) {
|
|
@@ -9966,17 +10784,17 @@ var TraceIndexService = class {
|
|
|
9966
10784
|
this.seen.set(key, { root: -1, dates: /* @__PURE__ */ new Map() });
|
|
9967
10785
|
return;
|
|
9968
10786
|
}
|
|
9969
|
-
if (!force &&
|
|
9970
|
-
const dates = [...
|
|
10787
|
+
if (!force && cached2 && cached2.root === rootMtime) {
|
|
10788
|
+
const dates = [...cached2.dates.keys()].sort();
|
|
9971
10789
|
const newest = dates.pop();
|
|
9972
10790
|
if (newest === void 0) return;
|
|
9973
10791
|
this.counters.gateStats += 1;
|
|
9974
10792
|
const newestMtime = await statMtime(path14.join(dir, newest));
|
|
9975
|
-
let changed = newestMtime === null || newestMtime !==
|
|
10793
|
+
let changed = newestMtime === null || newestMtime !== cached2.dates.get(newest);
|
|
9976
10794
|
if (!changed && dates.length > 0) {
|
|
9977
10795
|
this.counters.gateStats += dates.length;
|
|
9978
10796
|
const older = await Promise.all(dates.map((d) => statMtime(path14.join(dir, d))));
|
|
9979
|
-
changed = older.some((m, i) => m === null || m !==
|
|
10797
|
+
changed = older.some((m, i) => m === null || m !== cached2.dates.get(dates[i]));
|
|
9980
10798
|
}
|
|
9981
10799
|
if (!changed) return;
|
|
9982
10800
|
}
|
|
@@ -9994,7 +10812,7 @@ var TraceIndexService = class {
|
|
|
9994
10812
|
const m = await statMtime(path14.join(dir, date));
|
|
9995
10813
|
if (m === null) continue;
|
|
9996
10814
|
next.dates.set(date, cacheable(m));
|
|
9997
|
-
if (!force &&
|
|
10815
|
+
if (!force && cached2?.dates.get(date) === m) continue;
|
|
9998
10816
|
const known = new Map(
|
|
9999
10817
|
(knownByDate.get(date) ?? []).map((r) => [`${r.sessionId}\0${r.fileIndex}`, r])
|
|
10000
10818
|
);
|
|
@@ -10436,8 +11254,8 @@ import {
|
|
|
10436
11254
|
attachedFileLine as attachedFileLine2,
|
|
10437
11255
|
attachedImageLine,
|
|
10438
11256
|
isSessionMeta as isSessionMeta6,
|
|
10439
|
-
matchAttachedFileLine,
|
|
10440
|
-
matchAttachedImageLine,
|
|
11257
|
+
matchAttachedFileLine as matchAttachedFileLine2,
|
|
11258
|
+
matchAttachedImageLine as matchAttachedImageLine2,
|
|
10441
11259
|
modelVisiblePath as modelVisiblePath2,
|
|
10442
11260
|
parseTraceLines as parseTraceLines2,
|
|
10443
11261
|
parseUserSteeringText as parseUserSteeringText3,
|
|
@@ -10474,12 +11292,12 @@ function rewriteForkText(text, sourceScratchpad, forkScratchpad) {
|
|
|
10474
11292
|
};
|
|
10475
11293
|
return text.split("\n").map((line) => {
|
|
10476
11294
|
const trimmed = line.trim();
|
|
10477
|
-
const image =
|
|
11295
|
+
const image = matchAttachedImageLine2(trimmed);
|
|
10478
11296
|
if (image !== null) {
|
|
10479
11297
|
const rewritten = rewriteTarget(image);
|
|
10480
11298
|
return rewritten === null ? line : line.replace(trimmed, attachedImageLine(rewritten));
|
|
10481
11299
|
}
|
|
10482
|
-
const file =
|
|
11300
|
+
const file = matchAttachedFileLine2(trimmed);
|
|
10483
11301
|
if (file !== null) {
|
|
10484
11302
|
const rewritten = rewriteTarget(file);
|
|
10485
11303
|
return rewritten === null ? line : line.replace(trimmed, attachedFileLine2(rewritten));
|
|
@@ -10531,9 +11349,9 @@ var TraceService = class {
|
|
|
10531
11349
|
}));
|
|
10532
11350
|
}
|
|
10533
11351
|
/** All shard reads funnel through here (deps.observeShardRead is the windowed-read tests' proof of which files were touched). */
|
|
10534
|
-
async readShard(
|
|
10535
|
-
this.deps.observeShardRead?.(
|
|
10536
|
-
return readTraceTolerant2(
|
|
11352
|
+
async readShard(path25) {
|
|
11353
|
+
this.deps.observeShardRead?.(path25);
|
|
11354
|
+
return readTraceTolerant2(path25);
|
|
10537
11355
|
}
|
|
10538
11356
|
/** Deletes all of this Session's Trace files (called when the Session is deleted); the index rows go with them. */
|
|
10539
11357
|
async deleteSessionTraces(projectId, agentId, sessionId) {
|
|
@@ -10590,8 +11408,8 @@ var TraceService = class {
|
|
|
10590
11408
|
}
|
|
10591
11409
|
/** A child session's concatenated raw shard messages, memoized per request; null = no Trace located. */
|
|
10592
11410
|
async readChildRaw(projectId, childSid, ctx) {
|
|
10593
|
-
const
|
|
10594
|
-
if (
|
|
11411
|
+
const cached2 = ctx.raw.get(childSid);
|
|
11412
|
+
if (cached2 !== void 0) return cached2;
|
|
10595
11413
|
const childAgent = await this.resolveChildAgent(projectId, childSid, ctx);
|
|
10596
11414
|
let raw = null;
|
|
10597
11415
|
if (childAgent) {
|
|
@@ -10683,11 +11501,11 @@ var TraceService = class {
|
|
|
10683
11501
|
const states = [];
|
|
10684
11502
|
for (let j = 0; j <= upto; j++) {
|
|
10685
11503
|
const file = files[j];
|
|
10686
|
-
const
|
|
11504
|
+
const cached2 = deserializePrefix(
|
|
10687
11505
|
this.deps.index.repo.getPageStats(projectId, agentId, sessionId, file.index)
|
|
10688
11506
|
);
|
|
10689
|
-
if (
|
|
10690
|
-
states.push(
|
|
11507
|
+
if (cached2 !== null) {
|
|
11508
|
+
states.push(cached2);
|
|
10691
11509
|
continue;
|
|
10692
11510
|
}
|
|
10693
11511
|
const state = cloneScanState(j === 0 ? initialScanState() : states[j - 1]);
|
|
@@ -10797,16 +11615,16 @@ var TraceService = class {
|
|
|
10797
11615
|
* resumes from the selected shard exactly as it would after a restart.
|
|
10798
11616
|
*/
|
|
10799
11617
|
async forkSessionTrace(projectId, agentId, sourceSessionId, position) {
|
|
10800
|
-
const
|
|
11618
|
+
const invalid2 = (message) => new HttpError(400, "invalid_fork_position", message);
|
|
10801
11619
|
const files = await this.locateAll(projectId, agentId, sourceSessionId);
|
|
10802
11620
|
const targetFilePos = files.findIndex((file) => file.index === position.fileIndex);
|
|
10803
|
-
if (targetFilePos < 0) throw
|
|
11621
|
+
if (targetFilePos < 0) throw invalid2("The selected reply no longer exists in this Session.");
|
|
10804
11622
|
const shards = [];
|
|
10805
11623
|
for (let i = 0; i <= targetFilePos; i++) shards.push(await this.readShard(files[i].path));
|
|
10806
11624
|
const targetShard = shards[targetFilePos];
|
|
10807
11625
|
const target = targetShard[position.ordinal];
|
|
10808
11626
|
if (!target || !isAssistantText(target)) {
|
|
10809
|
-
throw
|
|
11627
|
+
throw invalid2("The fork position must identify a completed assistant reply.");
|
|
10810
11628
|
}
|
|
10811
11629
|
let inCompaction = false;
|
|
10812
11630
|
for (let filePos = 0; filePos <= targetFilePos; filePos++) {
|
|
@@ -10821,7 +11639,7 @@ var TraceService = class {
|
|
|
10821
11639
|
}
|
|
10822
11640
|
}
|
|
10823
11641
|
}
|
|
10824
|
-
if (inCompaction) throw
|
|
11642
|
+
if (inCompaction) throw invalid2("A compaction summary cannot be used as a fork point.");
|
|
10825
11643
|
let cutOrdinal = position.ordinal + 1;
|
|
10826
11644
|
let completedRequest = false;
|
|
10827
11645
|
for (let i = position.ordinal + 1; i < targetShard.length; i++) {
|
|
@@ -10831,21 +11649,21 @@ var TraceService = class {
|
|
|
10831
11649
|
break;
|
|
10832
11650
|
}
|
|
10833
11651
|
if (isAssistantText(msg)) {
|
|
10834
|
-
throw
|
|
11652
|
+
throw invalid2("The selected record is not the final assistant reply of its Task.");
|
|
10835
11653
|
}
|
|
10836
11654
|
cutOrdinal = i + 1;
|
|
10837
11655
|
if (msg.type === "event_msg" && p.type === "request_end") {
|
|
10838
11656
|
if (p.status === "completed") completedRequest = true;
|
|
10839
|
-
else throw
|
|
11657
|
+
else throw invalid2("The selected assistant reply belongs to an incomplete request.");
|
|
10840
11658
|
}
|
|
10841
11659
|
}
|
|
10842
|
-
if (!completedRequest) throw
|
|
11660
|
+
if (!completedRequest) throw invalid2("The selected assistant reply has not completed yet.");
|
|
10843
11661
|
const resumed = resumeTrace(targetShard.slice(0, cutOrdinal));
|
|
10844
11662
|
if (resumed.contextClosed || resumed.carryOver.length > 0 || !resumed.history.some((message) => message === target)) {
|
|
10845
|
-
throw
|
|
11663
|
+
throw invalid2("The selected reply is not a complete resumable Task boundary.");
|
|
10846
11664
|
}
|
|
10847
11665
|
const firstMeta = shards.flat().find(isSessionMeta6);
|
|
10848
|
-
if (!firstMeta) throw
|
|
11666
|
+
if (!firstMeta) throw invalid2("The source Trace has no session metadata.");
|
|
10849
11667
|
const created = /* @__PURE__ */ new Date();
|
|
10850
11668
|
const createdAt = created.toISOString();
|
|
10851
11669
|
const newSessionId = forkSessionId(created);
|
|
@@ -10898,7 +11716,7 @@ var TraceService = class {
|
|
|
10898
11716
|
const limit = filePos === targetFilePos ? cutOrdinal : sourceMessages.length;
|
|
10899
11717
|
const records = sourceMessages.slice(0, limit).map(rewrite);
|
|
10900
11718
|
if (!isSessionMeta6(records[0])) {
|
|
10901
|
-
throw
|
|
11719
|
+
throw invalid2(`Trace shard ${sourceFile.index} does not start with session metadata.`);
|
|
10902
11720
|
}
|
|
10903
11721
|
const dir = path15.join(tracesDir3(this.root, projectId, agentId), sourceFile.date);
|
|
10904
11722
|
await fs20.mkdir(dir, { recursive: true });
|
|
@@ -11375,20 +12193,20 @@ var TraceService = class {
|
|
|
11375
12193
|
* import round-trip lands in the same date dir on non-UTC servers.
|
|
11376
12194
|
*/
|
|
11377
12195
|
async importTraceFile(projectId, agentId, content) {
|
|
11378
|
-
const
|
|
12196
|
+
const invalid2 = (message) => new HttpError(400, "invalid_trace", message);
|
|
11379
12197
|
let records;
|
|
11380
12198
|
try {
|
|
11381
12199
|
records = parseTraceLines2(content, { onMalformed: "throw" });
|
|
11382
12200
|
} catch {
|
|
11383
|
-
throw
|
|
12201
|
+
throw invalid2("The file is not valid Trace JSONL.");
|
|
11384
12202
|
}
|
|
11385
|
-
if (records.length === 0) throw
|
|
12203
|
+
if (records.length === 0) throw invalid2("The file contains no Trace records.");
|
|
11386
12204
|
const first = records[0];
|
|
11387
12205
|
if (!isSessionMeta6(first))
|
|
11388
|
-
throw
|
|
12206
|
+
throw invalid2("The first record of a Trace file must be session_meta.");
|
|
11389
12207
|
const sessionId = first.payload.session_id;
|
|
11390
12208
|
if (typeof sessionId !== "string" || !IMPORT_SESSION_ID_RE.test(sessionId)) {
|
|
11391
|
-
throw
|
|
12209
|
+
throw invalid2("session_meta carries a missing or invalid session_id.");
|
|
11392
12210
|
}
|
|
11393
12211
|
const duplicate = () => new HttpError(
|
|
11394
12212
|
409,
|
|
@@ -11525,7 +12343,7 @@ var UpdateCheckService = class {
|
|
|
11525
12343
|
};
|
|
11526
12344
|
|
|
11527
12345
|
// src/services/usage-service.ts
|
|
11528
|
-
var ERROR_RECENT_N =
|
|
12346
|
+
var ERROR_RECENT_N = 10;
|
|
11529
12347
|
function costOf(sums, rates) {
|
|
11530
12348
|
return (sums.cacheRead * rates.cacheRead + sums.cacheWrite * rates.cacheWrite + sums.output * rates.output) / 1e6;
|
|
11531
12349
|
}
|
|
@@ -11554,23 +12372,59 @@ var UsageService = class {
|
|
|
11554
12372
|
...from !== void 0 ? { from } : {},
|
|
11555
12373
|
...to !== void 0 ? { to } : {}
|
|
11556
12374
|
});
|
|
12375
|
+
const ts = {
|
|
12376
|
+
...q.fromTs !== void 0 ? { fromTs: q.fromTs } : {},
|
|
12377
|
+
...q.toTs !== void 0 ? { toTs: q.toTs } : {}
|
|
12378
|
+
};
|
|
11557
12379
|
const todayRows = this.usage.bucketByModel(projectId, win(today, today));
|
|
11558
12380
|
const last7dRows = this.usage.bucketByModel(projectId, win(localDateMinusDays(this.now(), 6)));
|
|
11559
|
-
const totalRows = this.usage.bucketByModel(projectId, win(q.from, q.to));
|
|
11560
|
-
const groupRows = this.usage.groupsByModel(projectId, q.groupBy,
|
|
11561
|
-
|
|
11562
|
-
|
|
11563
|
-
|
|
12381
|
+
const totalRows = this.usage.bucketByModel(projectId, { ...win(q.from, q.to), ...ts });
|
|
12382
|
+
const groupRows = this.usage.groupsByModel(projectId, q.groupBy, {
|
|
12383
|
+
...win(q.from, q.to),
|
|
12384
|
+
...ts
|
|
12385
|
+
});
|
|
12386
|
+
const granularity = q.granularity ?? "day";
|
|
12387
|
+
const seriesFrom = q.from ?? localDateMinusDays(this.now(), 29);
|
|
12388
|
+
const seriesTo = q.to ?? today;
|
|
12389
|
+
let bucketKeys;
|
|
12390
|
+
if (granularity === "minute" || granularity === "hour" && ts.fromTs !== void 0) {
|
|
12391
|
+
if (ts.fromTs === void 0 || ts.toTs === void 0) {
|
|
12392
|
+
throw badRequest(
|
|
12393
|
+
"fromTs and toTs must be given together, and minute granularity requires them."
|
|
12394
|
+
);
|
|
12395
|
+
}
|
|
12396
|
+
bucketKeys = enumerateTsBuckets(new Date(ts.fromTs), new Date(ts.toTs), granularity, 500);
|
|
12397
|
+
} else {
|
|
12398
|
+
bucketKeys = enumerateBuckets(seriesFrom, seriesTo, granularity, 500);
|
|
12399
|
+
}
|
|
12400
|
+
if (bucketKeys.length > 500) {
|
|
12401
|
+
throw badRequest(
|
|
12402
|
+
"Date range too wide for this granularity; narrow the range or coarsen the granularity."
|
|
12403
|
+
);
|
|
12404
|
+
}
|
|
12405
|
+
const seriesRows = this.usage.seriesByModel(projectId, granularity, {
|
|
12406
|
+
...win(seriesFrom, seriesTo),
|
|
12407
|
+
...ts
|
|
12408
|
+
});
|
|
12409
|
+
const agentFree = {
|
|
11564
12410
|
...q.provider !== void 0 ? { provider: q.provider } : {},
|
|
11565
12411
|
...q.modelId !== void 0 ? { modelId: q.modelId } : {},
|
|
11566
12412
|
...q.from !== void 0 ? { from: q.from } : {},
|
|
11567
|
-
...q.to !== void 0 ? { to: q.to } : {}
|
|
12413
|
+
...q.to !== void 0 ? { to: q.to } : {},
|
|
12414
|
+
...ts
|
|
12415
|
+
};
|
|
12416
|
+
const agentBucketRows = this.usage.agentSeries(projectId, granularity, {
|
|
12417
|
+
...agentFree,
|
|
12418
|
+
from: seriesFrom,
|
|
12419
|
+
to: seriesTo
|
|
11568
12420
|
});
|
|
11569
|
-
const
|
|
12421
|
+
const modelFiltered = q.provider !== void 0 || q.modelId !== void 0;
|
|
12422
|
+
const modelBucketRows = modelFiltered ? this.usage.seriesByModel(projectId, granularity, {
|
|
11570
12423
|
...q.agentId !== void 0 ? { agentId: q.agentId } : {},
|
|
11571
|
-
|
|
11572
|
-
|
|
11573
|
-
|
|
12424
|
+
from: seriesFrom,
|
|
12425
|
+
to: seriesTo,
|
|
12426
|
+
...ts
|
|
12427
|
+
}) : seriesRows;
|
|
11574
12428
|
const errorFilter = {
|
|
11575
12429
|
...q.agentId !== void 0 ? { agentId: q.agentId } : {},
|
|
11576
12430
|
...q.from !== void 0 ? { from: q.from } : {},
|
|
@@ -11580,19 +12434,12 @@ var UsageService = class {
|
|
|
11580
12434
|
};
|
|
11581
12435
|
const rates = /* @__PURE__ */ new Map();
|
|
11582
12436
|
const allRefs = /* @__PURE__ */ new Map();
|
|
11583
|
-
for (const r of [...todayRows, ...last7dRows, ...totalRows, ...groupRows, ...
|
|
12437
|
+
for (const r of [...todayRows, ...last7dRows, ...totalRows, ...groupRows, ...seriesRows]) {
|
|
11584
12438
|
allRefs.set(refKey2(r.provider, r.modelId), { provider: r.provider, modelId: r.modelId });
|
|
11585
12439
|
}
|
|
11586
12440
|
for (const [key, ref] of allRefs) {
|
|
11587
12441
|
rates.set(key, await this.lookupPricing(projectId, ref.provider, ref.modelId));
|
|
11588
12442
|
}
|
|
11589
|
-
const byAgentMap = /* @__PURE__ */ new Map();
|
|
11590
|
-
for (const r of agentRows) {
|
|
11591
|
-
const acc = byAgentMap.get(r.key) ?? { requests: 0, total: 0 };
|
|
11592
|
-
acc.requests += r.requests;
|
|
11593
|
-
acc.total += r.total;
|
|
11594
|
-
byAgentMap.set(r.key, acc);
|
|
11595
|
-
}
|
|
11596
12443
|
return {
|
|
11597
12444
|
summary: {
|
|
11598
12445
|
today: this.foldBucket(todayRows, rates),
|
|
@@ -11601,9 +12448,10 @@ var UsageService = class {
|
|
|
11601
12448
|
},
|
|
11602
12449
|
groupBy: q.groupBy,
|
|
11603
12450
|
groups: this.foldGroups(groupRows, rates, q.groupBy),
|
|
11604
|
-
|
|
11605
|
-
|
|
11606
|
-
|
|
12451
|
+
granularity,
|
|
12452
|
+
series: this.foldSeries(bucketKeys, seriesRows, rates),
|
|
12453
|
+
byAgentSeries: foldAgentSeries(bucketKeys, agentBucketRows),
|
|
12454
|
+
byModelSeries: foldModelSeries(bucketKeys, modelBucketRows),
|
|
11607
12455
|
errors: this.foldErrors(projectId, errorFilter),
|
|
11608
12456
|
agentIds: this.usage.distinctAgentIds(projectId),
|
|
11609
12457
|
models: this.usage.distinctModels(projectId)
|
|
@@ -11690,40 +12538,102 @@ var UsageService = class {
|
|
|
11690
12538
|
else out.sort((a, b) => b.total - a.total);
|
|
11691
12539
|
return out;
|
|
11692
12540
|
}
|
|
11693
|
-
|
|
11694
|
-
|
|
12541
|
+
/**
|
|
12542
|
+
* Fold the per-Model series rows onto the zero-filled bucket skeleton: every
|
|
12543
|
+
* enumerated bucket appears exactly once, in order, so line charts never
|
|
12544
|
+
* connect across a silent gap. A row whose key falls outside the skeleton
|
|
12545
|
+
* cannot happen for day/week/month (keys derive from the filtered date
|
|
12546
|
+
* column) and is dropped defensively for minute/hour, whose keys come from
|
|
12547
|
+
* `ts` and can therefore fall outside a skeleton built from `date` if a row
|
|
12548
|
+
* was recorded under a different clock.
|
|
12549
|
+
*/
|
|
12550
|
+
foldSeries(keys, rows, rates) {
|
|
12551
|
+
const byKey = new Map(
|
|
12552
|
+
keys.map((bucket) => [
|
|
12553
|
+
bucket,
|
|
12554
|
+
{
|
|
12555
|
+
bucket,
|
|
12556
|
+
cacheRead: 0,
|
|
12557
|
+
cacheWrite: 0,
|
|
12558
|
+
output: 0,
|
|
12559
|
+
total: 0,
|
|
12560
|
+
cost: null,
|
|
12561
|
+
requests: 0,
|
|
12562
|
+
completed: 0,
|
|
12563
|
+
denominator: 0
|
|
12564
|
+
}
|
|
12565
|
+
])
|
|
12566
|
+
);
|
|
11695
12567
|
for (const r of rows) {
|
|
11696
|
-
const acc =
|
|
11697
|
-
|
|
11698
|
-
cacheRead: 0,
|
|
11699
|
-
cacheWrite: 0,
|
|
11700
|
-
output: 0,
|
|
11701
|
-
cost: null
|
|
11702
|
-
};
|
|
11703
|
-
acc.total += r.total;
|
|
12568
|
+
const acc = byKey.get(r.key);
|
|
12569
|
+
if (!acc) continue;
|
|
11704
12570
|
acc.cacheRead += r.cacheRead;
|
|
11705
12571
|
acc.cacheWrite += r.cacheWrite;
|
|
11706
12572
|
acc.output += r.output;
|
|
12573
|
+
acc.total += r.total;
|
|
12574
|
+
acc.requests += r.requests;
|
|
12575
|
+
acc.completed += r.completed;
|
|
12576
|
+
acc.denominator += r.denominator;
|
|
11707
12577
|
const rate = rates.get(refKey2(r.provider, r.modelId));
|
|
11708
12578
|
if (rate) acc.cost = (acc.cost ?? 0) + costOf(r, rate);
|
|
11709
|
-
byDate.set(r.key, acc);
|
|
11710
12579
|
}
|
|
11711
|
-
return
|
|
11712
|
-
date,
|
|
11713
|
-
total: v.total,
|
|
11714
|
-
cacheRead: v.cacheRead,
|
|
11715
|
-
cacheWrite: v.cacheWrite,
|
|
11716
|
-
output: v.output,
|
|
11717
|
-
cost: v.cost
|
|
11718
|
-
}));
|
|
12580
|
+
return keys.map((k) => byKey.get(k));
|
|
11719
12581
|
}
|
|
11720
12582
|
};
|
|
12583
|
+
var sumOf = (a) => a.reduce((s, v) => s + v, 0);
|
|
12584
|
+
var zeros = (n) => new Array(n).fill(0);
|
|
12585
|
+
function foldAgentSeries(keys, rows) {
|
|
12586
|
+
const idx = new Map(keys.map((k, i) => [k, i]));
|
|
12587
|
+
const byAgent = /* @__PURE__ */ new Map();
|
|
12588
|
+
for (const r of rows) {
|
|
12589
|
+
const i = idx.get(r.key);
|
|
12590
|
+
if (i === void 0) continue;
|
|
12591
|
+
let s = byAgent.get(r.agentId);
|
|
12592
|
+
if (!s) {
|
|
12593
|
+
s = {
|
|
12594
|
+
agentId: r.agentId,
|
|
12595
|
+
requests: zeros(keys.length),
|
|
12596
|
+
completed: zeros(keys.length),
|
|
12597
|
+
denominator: zeros(keys.length)
|
|
12598
|
+
};
|
|
12599
|
+
byAgent.set(r.agentId, s);
|
|
12600
|
+
}
|
|
12601
|
+
s.requests[i] += r.requests;
|
|
12602
|
+
s.completed[i] += r.completed;
|
|
12603
|
+
s.denominator[i] += r.denominator;
|
|
12604
|
+
}
|
|
12605
|
+
return [...byAgent.values()].sort((a, b) => sumOf(b.requests) - sumOf(a.requests));
|
|
12606
|
+
}
|
|
12607
|
+
function foldModelSeries(keys, rows) {
|
|
12608
|
+
const idx = new Map(keys.map((k, i) => [k, i]));
|
|
12609
|
+
const byModel = /* @__PURE__ */ new Map();
|
|
12610
|
+
for (const r of rows) {
|
|
12611
|
+
const i = idx.get(r.key);
|
|
12612
|
+
if (i === void 0) continue;
|
|
12613
|
+
const key = refKey2(r.provider, r.modelId);
|
|
12614
|
+
let s = byModel.get(key);
|
|
12615
|
+
if (!s) {
|
|
12616
|
+
s = {
|
|
12617
|
+
provider: r.provider,
|
|
12618
|
+
modelId: r.modelId,
|
|
12619
|
+
requests: zeros(keys.length),
|
|
12620
|
+
completed: zeros(keys.length),
|
|
12621
|
+
denominator: zeros(keys.length)
|
|
12622
|
+
};
|
|
12623
|
+
byModel.set(key, s);
|
|
12624
|
+
}
|
|
12625
|
+
s.requests[i] += r.requests;
|
|
12626
|
+
s.completed[i] += r.completed;
|
|
12627
|
+
s.denominator[i] += r.denominator;
|
|
12628
|
+
}
|
|
12629
|
+
return [...byModel.values()].sort((a, b) => sumOf(b.requests) - sumOf(a.requests));
|
|
12630
|
+
}
|
|
11721
12631
|
|
|
11722
12632
|
// src/hmr/host.ts
|
|
11723
|
-
import
|
|
11724
|
-
import
|
|
11725
|
-
import
|
|
11726
|
-
import
|
|
12633
|
+
import crypto2 from "crypto";
|
|
12634
|
+
import fs22 from "fs";
|
|
12635
|
+
import fsp2 from "fs/promises";
|
|
12636
|
+
import path21 from "path";
|
|
11727
12637
|
import zlib from "zlib";
|
|
11728
12638
|
import { pathToFileURL } from "url";
|
|
11729
12639
|
import { boot, initialDoc, upgrade } from "@prismshadow/penguin-core/kernel";
|
|
@@ -11757,21 +12667,1148 @@ var HotResources = class {
|
|
|
11757
12667
|
|
|
11758
12668
|
// src/platform/platform.ts
|
|
11759
12669
|
import { defineIface, schema, type } from "@prismshadow/penguin-core/kernel";
|
|
12670
|
+
|
|
12671
|
+
// src/platform/terminal/manager.ts
|
|
12672
|
+
import fsp from "fs/promises";
|
|
12673
|
+
import path20 from "path";
|
|
12674
|
+
|
|
12675
|
+
// src/terminal/spawn-helper.ts
|
|
12676
|
+
import fs21 from "fs";
|
|
12677
|
+
import path16 from "path";
|
|
12678
|
+
import { createRequire } from "module";
|
|
12679
|
+
var EXEC_BITS = 73;
|
|
12680
|
+
function repairSpawnHelpers(pkgDir, arch = process.arch) {
|
|
12681
|
+
const candidates = [
|
|
12682
|
+
path16.join(pkgDir, "build", "Release", "spawn-helper"),
|
|
12683
|
+
path16.join(pkgDir, "build", "Debug", "spawn-helper"),
|
|
12684
|
+
path16.join(pkgDir, "prebuilds", `darwin-${arch}`, "spawn-helper")
|
|
12685
|
+
];
|
|
12686
|
+
const repaired = [];
|
|
12687
|
+
for (const helper of candidates) {
|
|
12688
|
+
let mode;
|
|
12689
|
+
try {
|
|
12690
|
+
mode = fs21.statSync(helper).mode;
|
|
12691
|
+
} catch {
|
|
12692
|
+
continue;
|
|
12693
|
+
}
|
|
12694
|
+
if ((mode & EXEC_BITS) !== 0) continue;
|
|
12695
|
+
try {
|
|
12696
|
+
fs21.chmodSync(helper, mode | EXEC_BITS);
|
|
12697
|
+
repaired.push(helper);
|
|
12698
|
+
} catch {
|
|
12699
|
+
}
|
|
12700
|
+
}
|
|
12701
|
+
return repaired;
|
|
12702
|
+
}
|
|
12703
|
+
function nodePtyDir() {
|
|
12704
|
+
try {
|
|
12705
|
+
return path16.dirname(createRequire(import.meta.url).resolve("node-pty/package.json"));
|
|
12706
|
+
} catch {
|
|
12707
|
+
return null;
|
|
12708
|
+
}
|
|
12709
|
+
}
|
|
12710
|
+
var repairAttempted = false;
|
|
12711
|
+
function ensureSpawnHelperExecutable() {
|
|
12712
|
+
if (process.platform !== "darwin" || repairAttempted) return;
|
|
12713
|
+
repairAttempted = true;
|
|
12714
|
+
const pkgDir = nodePtyDir();
|
|
12715
|
+
if (pkgDir === null) return;
|
|
12716
|
+
for (const fixed of repairSpawnHelpers(pkgDir)) {
|
|
12717
|
+
console.log(`[terminal] restored the exec bit on ${fixed} (node-pty ships it as 0644)`);
|
|
12718
|
+
}
|
|
12719
|
+
}
|
|
12720
|
+
function spawnHelperHint() {
|
|
12721
|
+
if (process.platform !== "darwin") return null;
|
|
12722
|
+
const pkgDir = nodePtyDir();
|
|
12723
|
+
if (pkgDir === null) return null;
|
|
12724
|
+
for (const helper of [
|
|
12725
|
+
path16.join(pkgDir, "build", "Release", "spawn-helper"),
|
|
12726
|
+
path16.join(pkgDir, "prebuilds", `darwin-${process.arch}`, "spawn-helper")
|
|
12727
|
+
]) {
|
|
12728
|
+
try {
|
|
12729
|
+
if ((fs21.statSync(helper).mode & EXEC_BITS) === 0) {
|
|
12730
|
+
return `${helper} is not executable \u2014 run: chmod +x "${helper}"`;
|
|
12731
|
+
}
|
|
12732
|
+
} catch {
|
|
12733
|
+
continue;
|
|
12734
|
+
}
|
|
12735
|
+
}
|
|
12736
|
+
return null;
|
|
12737
|
+
}
|
|
12738
|
+
|
|
12739
|
+
// src/platform/terminal/session.ts
|
|
12740
|
+
import os2 from "os";
|
|
12741
|
+
import path19 from "path";
|
|
12742
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
12743
|
+
import xterm from "@xterm/headless";
|
|
12744
|
+
|
|
12745
|
+
// src/platform/terminal/pty-module.ts
|
|
12746
|
+
import { createRequire as createRequire2 } from "module";
|
|
12747
|
+
import path17 from "path";
|
|
12748
|
+
var cached = null;
|
|
12749
|
+
function loadNodePty(resources) {
|
|
12750
|
+
if (cached !== null) return cached;
|
|
12751
|
+
const attempts = [
|
|
12752
|
+
// Packaged server: this module is part of the runtime's own graph.
|
|
12753
|
+
() => createRequire2(import.meta.url)("node-pty"),
|
|
12754
|
+
// Pushed bundle: the binary rides along as an asset, laid out as a real package dir.
|
|
12755
|
+
() => {
|
|
12756
|
+
const dir = resources.claim(ASSETS_RESOURCE_ID);
|
|
12757
|
+
if (dir === void 0) throw new Error("no assets directory published");
|
|
12758
|
+
return createRequire2(path17.join(dir, "index.mjs"))("node-pty");
|
|
12759
|
+
}
|
|
12760
|
+
];
|
|
12761
|
+
const failures = [];
|
|
12762
|
+
for (const attempt of attempts) {
|
|
12763
|
+
try {
|
|
12764
|
+
cached = attempt();
|
|
12765
|
+
return cached;
|
|
12766
|
+
} catch (err) {
|
|
12767
|
+
failures.push(err instanceof Error ? err.message : String(err));
|
|
12768
|
+
}
|
|
12769
|
+
}
|
|
12770
|
+
throw new Error(`node-pty is unavailable to the platform: ${failures.join(" / ")}`);
|
|
12771
|
+
}
|
|
12772
|
+
|
|
12773
|
+
// src/platform/terminal/shell.ts
|
|
12774
|
+
import { spawnSync } from "child_process";
|
|
12775
|
+
import path18 from "path";
|
|
12776
|
+
function shellName(shell) {
|
|
12777
|
+
return path18.win32.basename(shell).replace(/\.(exe|cmd|bat)$/i, "").toLowerCase();
|
|
12778
|
+
}
|
|
12779
|
+
function defaultOnPath(cmd) {
|
|
12780
|
+
try {
|
|
12781
|
+
const res = spawnSync("where", [cmd], {
|
|
12782
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
12783
|
+
windowsHide: true
|
|
12784
|
+
});
|
|
12785
|
+
return res.status === 0 && (res.stdout?.toString("utf8").trim().length ?? 0) > 0;
|
|
12786
|
+
} catch {
|
|
12787
|
+
return false;
|
|
12788
|
+
}
|
|
12789
|
+
}
|
|
12790
|
+
function resolveDefaultShell(env = process.env, platform = process.platform, opts = {}) {
|
|
12791
|
+
const explicit = env.PENGUIN_SHELL?.trim();
|
|
12792
|
+
if (explicit) return explicit;
|
|
12793
|
+
if (platform !== "win32") return env.SHELL || "/bin/sh";
|
|
12794
|
+
const onPath = opts.onPath ?? defaultOnPath;
|
|
12795
|
+
if (onPath("pwsh")) return "pwsh.exe";
|
|
12796
|
+
if (onPath("powershell")) return "powershell.exe";
|
|
12797
|
+
return env.ComSpec || env.COMSPEC || "C:\\Windows\\System32\\cmd.exe";
|
|
12798
|
+
}
|
|
12799
|
+
function shellArgs(shell) {
|
|
12800
|
+
const name = shellName(shell);
|
|
12801
|
+
if (name === "pwsh" || name === "powershell") return ["-NoLogo"];
|
|
12802
|
+
if (name === "cmd") return [];
|
|
12803
|
+
return ["-l"];
|
|
12804
|
+
}
|
|
12805
|
+
var cachedDefault = null;
|
|
12806
|
+
function defaultTerminalShell() {
|
|
12807
|
+
if (cachedDefault === null) cachedDefault = resolveDefaultShell();
|
|
12808
|
+
return cachedDefault;
|
|
12809
|
+
}
|
|
12810
|
+
|
|
12811
|
+
// src/platform/terminal/input-mode.ts
|
|
12812
|
+
var TRACKED_MODES = /* @__PURE__ */ new Set([
|
|
12813
|
+
1,
|
|
12814
|
+
// DECCKM, application cursor keys
|
|
12815
|
+
7,
|
|
12816
|
+
// DECAWM, auto-wrap
|
|
12817
|
+
12,
|
|
12818
|
+
// cursor blink
|
|
12819
|
+
25,
|
|
12820
|
+
// DECTCEM, cursor visibility
|
|
12821
|
+
66,
|
|
12822
|
+
// DECNKM, application keypad
|
|
12823
|
+
1e3,
|
|
12824
|
+
// mouse: click tracking
|
|
12825
|
+
1002,
|
|
12826
|
+
// mouse: button-event tracking
|
|
12827
|
+
1003,
|
|
12828
|
+
// mouse: any-event tracking
|
|
12829
|
+
1004,
|
|
12830
|
+
// focus reporting
|
|
12831
|
+
1005,
|
|
12832
|
+
// mouse: utf-8 coordinates
|
|
12833
|
+
1006,
|
|
12834
|
+
// mouse: SGR coordinates
|
|
12835
|
+
1015,
|
|
12836
|
+
// mouse: urxvt coordinates
|
|
12837
|
+
1016,
|
|
12838
|
+
// mouse: SGR pixel coordinates
|
|
12839
|
+
2004
|
|
12840
|
+
// bracketed paste
|
|
12841
|
+
]);
|
|
12842
|
+
var DEFAULT_ON = /* @__PURE__ */ new Set([7, 25]);
|
|
12843
|
+
var MAX_CARRY = 64;
|
|
12844
|
+
var DEC_PRIVATE_MODE_PATTERN = /\x1b\[\?([0-9;]*)([hl])/g;
|
|
12845
|
+
var TerminalInputModeTracker = class {
|
|
12846
|
+
enabled = new Set(DEFAULT_ON);
|
|
12847
|
+
/** Tail of the previous chunk that may hold the start of a split escape sequence. */
|
|
12848
|
+
carry = "";
|
|
12849
|
+
feed(chunk) {
|
|
12850
|
+
const text = this.carry + chunk;
|
|
12851
|
+
DEC_PRIVATE_MODE_PATTERN.lastIndex = 0;
|
|
12852
|
+
let match;
|
|
12853
|
+
let lastEnd = 0;
|
|
12854
|
+
while ((match = DEC_PRIVATE_MODE_PATTERN.exec(text)) !== null) {
|
|
12855
|
+
lastEnd = match.index + match[0].length;
|
|
12856
|
+
const on = match[2] === "h";
|
|
12857
|
+
for (const raw of (match[1] ?? "").split(";")) {
|
|
12858
|
+
if (raw === "") continue;
|
|
12859
|
+
const mode = Number.parseInt(raw, 10);
|
|
12860
|
+
if (!TRACKED_MODES.has(mode)) continue;
|
|
12861
|
+
if (on) this.enabled.add(mode);
|
|
12862
|
+
else this.enabled.delete(mode);
|
|
12863
|
+
}
|
|
12864
|
+
}
|
|
12865
|
+
const tailStart = Math.max(lastEnd, text.length - MAX_CARRY);
|
|
12866
|
+
const tail = text.slice(tailStart);
|
|
12867
|
+
const escIndex = tail.lastIndexOf("\x1B");
|
|
12868
|
+
this.carry = escIndex === -1 ? "" : tail.slice(escIndex);
|
|
12869
|
+
}
|
|
12870
|
+
isEnabled(mode) {
|
|
12871
|
+
return this.enabled.has(mode);
|
|
12872
|
+
}
|
|
12873
|
+
/**
|
|
12874
|
+
* Escape sequences that put a freshly attached client back into the modes the running
|
|
12875
|
+
* program believes it is in. Emitted right after the restored screen content.
|
|
12876
|
+
*/
|
|
12877
|
+
preamble() {
|
|
12878
|
+
const on = [];
|
|
12879
|
+
const off = [];
|
|
12880
|
+
for (const mode of TRACKED_MODES) {
|
|
12881
|
+
const enabled = this.enabled.has(mode);
|
|
12882
|
+
if (enabled === DEFAULT_ON.has(mode)) continue;
|
|
12883
|
+
(enabled ? on : off).push(mode);
|
|
12884
|
+
}
|
|
12885
|
+
let out = "";
|
|
12886
|
+
if (on.length > 0) out += `\x1B[?${on.join(";")}h`;
|
|
12887
|
+
if (off.length > 0) out += `\x1B[?${off.join(";")}l`;
|
|
12888
|
+
return out;
|
|
12889
|
+
}
|
|
12890
|
+
};
|
|
12891
|
+
|
|
12892
|
+
// src/platform/terminal/snapshot.ts
|
|
12893
|
+
var DEFAULT_RESTORE_SCROLLBACK_LINES = 400;
|
|
12894
|
+
var MAX_RESTORE_SCROLLBACK_LINES = 2e3;
|
|
12895
|
+
function styleOf(cell) {
|
|
12896
|
+
const params = [];
|
|
12897
|
+
if (cell.isBold()) params.push("1");
|
|
12898
|
+
if (cell.isDim()) params.push("2");
|
|
12899
|
+
if (cell.isItalic()) params.push("3");
|
|
12900
|
+
if (cell.isUnderline()) params.push("4");
|
|
12901
|
+
if (cell.isBlink()) params.push("5");
|
|
12902
|
+
if (cell.isInverse()) params.push("7");
|
|
12903
|
+
if (cell.isInvisible()) params.push("8");
|
|
12904
|
+
if (cell.isStrikethrough()) params.push("9");
|
|
12905
|
+
if (cell.isOverline()) params.push("53");
|
|
12906
|
+
if (cell.isFgRGB()) {
|
|
12907
|
+
const rgb = cell.getFgColor();
|
|
12908
|
+
params.push(`38;2;${rgb >> 16 & 255};${rgb >> 8 & 255};${rgb & 255}`);
|
|
12909
|
+
} else if (cell.isFgPalette()) {
|
|
12910
|
+
params.push(`38;5;${cell.getFgColor()}`);
|
|
12911
|
+
}
|
|
12912
|
+
if (cell.isBgRGB()) {
|
|
12913
|
+
const rgb = cell.getBgColor();
|
|
12914
|
+
params.push(`48;2;${rgb >> 16 & 255};${rgb >> 8 & 255};${rgb & 255}`);
|
|
12915
|
+
} else if (cell.isBgPalette()) {
|
|
12916
|
+
params.push(`48;5;${cell.getBgColor()}`);
|
|
12917
|
+
}
|
|
12918
|
+
return params.join(";");
|
|
12919
|
+
}
|
|
12920
|
+
function isUntouched(cell) {
|
|
12921
|
+
return cell.getChars() === "" && cell.isAttributeDefault();
|
|
12922
|
+
}
|
|
12923
|
+
function renderLine(line, cols, scratch) {
|
|
12924
|
+
let lastMeaningful = -1;
|
|
12925
|
+
for (let x = 0; x < cols; x += 1) {
|
|
12926
|
+
const cell = line.getCell(x, scratch);
|
|
12927
|
+
if (cell && !isUntouched(cell)) lastMeaningful = x;
|
|
12928
|
+
}
|
|
12929
|
+
if (lastMeaningful === -1) return "";
|
|
12930
|
+
let out = "";
|
|
12931
|
+
let currentStyle = "";
|
|
12932
|
+
for (let x = 0; x <= lastMeaningful; x += 1) {
|
|
12933
|
+
const cell = line.getCell(x, scratch);
|
|
12934
|
+
if (!cell) continue;
|
|
12935
|
+
const width = cell.getWidth();
|
|
12936
|
+
if (width === 0) continue;
|
|
12937
|
+
const style = styleOf(cell);
|
|
12938
|
+
if (style !== currentStyle) {
|
|
12939
|
+
out += style === "" ? "\x1B[0m" : `\x1B[0;${style}m`;
|
|
12940
|
+
currentStyle = style;
|
|
12941
|
+
}
|
|
12942
|
+
const chars = cell.getChars();
|
|
12943
|
+
out += chars === "" ? " " : chars;
|
|
12944
|
+
}
|
|
12945
|
+
if (currentStyle !== "") out += "\x1B[0m";
|
|
12946
|
+
return out;
|
|
12947
|
+
}
|
|
12948
|
+
function renderRestoreAnsi(terminal, options = {}) {
|
|
12949
|
+
const buffer = terminal.buffer.active;
|
|
12950
|
+
const scratch = buffer.getNullCell();
|
|
12951
|
+
const rows = terminal.rows;
|
|
12952
|
+
const cols = terminal.cols;
|
|
12953
|
+
const isAlternate = buffer.type === "alternate";
|
|
12954
|
+
const requested = Math.min(
|
|
12955
|
+
Math.max(options.scrollbackLines ?? DEFAULT_RESTORE_SCROLLBACK_LINES, 0),
|
|
12956
|
+
MAX_RESTORE_SCROLLBACK_LINES
|
|
12957
|
+
);
|
|
12958
|
+
const scrollbackLines = isAlternate ? 0 : Math.min(requested, buffer.baseY);
|
|
12959
|
+
const firstLine = buffer.baseY - scrollbackLines;
|
|
12960
|
+
const lastLine = buffer.baseY + rows - 1;
|
|
12961
|
+
const parts = [];
|
|
12962
|
+
parts.push("\x1B[0m\x1B[?1049l\x1B[H\x1B[2J\x1B[3J");
|
|
12963
|
+
if (isAlternate) parts.push("\x1B[?1049h\x1B[H");
|
|
12964
|
+
for (let y = firstLine; y <= lastLine; y += 1) {
|
|
12965
|
+
if (y > firstLine) parts.push("\r\n");
|
|
12966
|
+
const line = buffer.getLine(y);
|
|
12967
|
+
if (line) parts.push(renderLine(line, cols, scratch));
|
|
12968
|
+
}
|
|
12969
|
+
parts.push(`\x1B[${buffer.cursorY + 1};${buffer.cursorX + 1}H`);
|
|
12970
|
+
if (options.inputModePreamble) parts.push(options.inputModePreamble);
|
|
12971
|
+
return parts.join("");
|
|
12972
|
+
}
|
|
12973
|
+
function resolveIndex(value, total, fallback) {
|
|
12974
|
+
if (total === 0) return fallback === "start" ? 0 : -1;
|
|
12975
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
12976
|
+
return fallback === "start" ? 0 : total - 1;
|
|
12977
|
+
}
|
|
12978
|
+
const resolved = value < 0 ? total + value : value;
|
|
12979
|
+
if (resolved < 0) return 0;
|
|
12980
|
+
if (resolved >= total) return total - 1;
|
|
12981
|
+
return resolved;
|
|
12982
|
+
}
|
|
12983
|
+
function captureLines(terminal, options = {}) {
|
|
12984
|
+
const buffer = terminal.buffer.active;
|
|
12985
|
+
const totalLines = buffer.baseY + terminal.rows;
|
|
12986
|
+
const all = [];
|
|
12987
|
+
for (let y = 0; y < totalLines; y += 1) {
|
|
12988
|
+
all.push(buffer.getLine(y)?.translateToString(true) ?? "");
|
|
12989
|
+
}
|
|
12990
|
+
const startIndex = resolveIndex(options.start, totalLines, "start");
|
|
12991
|
+
const endIndex = resolveIndex(options.end, totalLines, "end");
|
|
12992
|
+
if (totalLines === 0 || startIndex > endIndex) return { lines: [], totalLines };
|
|
12993
|
+
return { lines: all.slice(startIndex, endIndex + 1), totalLines };
|
|
12994
|
+
}
|
|
12995
|
+
|
|
12996
|
+
// src/platform/terminal/size-ownership.ts
|
|
12997
|
+
function applyTerminalSize(state, request) {
|
|
12998
|
+
if (request.intent === "update" && state.ownerId !== request.connectionId) {
|
|
12999
|
+
return { state, apply: false };
|
|
13000
|
+
}
|
|
13001
|
+
const nextState = {
|
|
13002
|
+
ownerId: request.connectionId,
|
|
13003
|
+
cols: request.cols,
|
|
13004
|
+
rows: request.rows
|
|
13005
|
+
};
|
|
13006
|
+
const unchanged = state.cols === request.cols && state.rows === request.rows;
|
|
13007
|
+
return { state: nextState, apply: !unchanged };
|
|
13008
|
+
}
|
|
13009
|
+
function releaseTerminalSize(state, connectionId) {
|
|
13010
|
+
return state.ownerId === connectionId ? { ...state, ownerId: null } : state;
|
|
13011
|
+
}
|
|
13012
|
+
|
|
13013
|
+
// src/platform/terminal/session.ts
|
|
13014
|
+
var { Terminal: HeadlessTerminal } = xterm;
|
|
13015
|
+
var DEFAULT_COLS = 80;
|
|
13016
|
+
var DEFAULT_ROWS = 24;
|
|
13017
|
+
var SCROLLBACK_LINES = 5e3;
|
|
13018
|
+
var TITLE_DEBOUNCE_MS = 150;
|
|
13019
|
+
var EXIT_OUTPUT_LINE_LIMIT = 12;
|
|
13020
|
+
var OSC_COLOR_QUERY_RESPONSES = /* @__PURE__ */ new Map([
|
|
13021
|
+
[10, "rgb:e6e6/e6e6/e6e6"],
|
|
13022
|
+
// foreground
|
|
13023
|
+
[11, "rgb:1414/1717/1a1a"],
|
|
13024
|
+
// background
|
|
13025
|
+
[12, "rgb:e6e6/e6e6/e6e6"]
|
|
13026
|
+
// cursor
|
|
13027
|
+
]);
|
|
13028
|
+
function buildTerminalEnv(extra, cwd) {
|
|
13029
|
+
const env = {};
|
|
13030
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
13031
|
+
if (typeof value === "string") env[key] = value;
|
|
13032
|
+
}
|
|
13033
|
+
Object.assign(env, extra ?? {});
|
|
13034
|
+
env.TERM = "xterm-256color";
|
|
13035
|
+
env.COLORTERM = "truecolor";
|
|
13036
|
+
env.PENGUIN_TERMINAL = "1";
|
|
13037
|
+
env.PWD = cwd;
|
|
13038
|
+
delete env.NODE_OPTIONS;
|
|
13039
|
+
return env;
|
|
13040
|
+
}
|
|
13041
|
+
var TerminalSession = class {
|
|
13042
|
+
id;
|
|
13043
|
+
seq;
|
|
13044
|
+
cwd;
|
|
13045
|
+
ownerUserId;
|
|
13046
|
+
createdAt = /* @__PURE__ */ new Date();
|
|
13047
|
+
name;
|
|
13048
|
+
terminal;
|
|
13049
|
+
ptyProcess;
|
|
13050
|
+
inputMode = new TerminalInputModeTracker();
|
|
13051
|
+
outputListeners = /* @__PURE__ */ new Set();
|
|
13052
|
+
exitListeners = /* @__PURE__ */ new Set();
|
|
13053
|
+
titleListeners = /* @__PURE__ */ new Set();
|
|
13054
|
+
title = null;
|
|
13055
|
+
pendingTitle = null;
|
|
13056
|
+
titleTimer = null;
|
|
13057
|
+
sizeState;
|
|
13058
|
+
exitInfo = null;
|
|
13059
|
+
disposed = false;
|
|
13060
|
+
constructor(options) {
|
|
13061
|
+
this.id = options.id ?? randomUUID4();
|
|
13062
|
+
this.seq = options.seq ?? 1;
|
|
13063
|
+
this.cwd = options.cwd;
|
|
13064
|
+
this.ownerUserId = options.ownerUserId;
|
|
13065
|
+
this.name = options.name ?? (path19.basename(options.cwd) || "terminal");
|
|
13066
|
+
const cols = clampDimension(options.cols, DEFAULT_COLS, 1e3);
|
|
13067
|
+
const rows = clampDimension(options.rows, DEFAULT_ROWS, 500);
|
|
13068
|
+
this.sizeState = { ownerId: null, cols, rows };
|
|
13069
|
+
this.terminal = new HeadlessTerminal({
|
|
13070
|
+
cols,
|
|
13071
|
+
rows,
|
|
13072
|
+
scrollback: SCROLLBACK_LINES,
|
|
13073
|
+
allowProposedApi: true
|
|
13074
|
+
});
|
|
13075
|
+
const shell = options.shell ?? defaultTerminalShell();
|
|
13076
|
+
ensureSpawnHelperExecutable();
|
|
13077
|
+
this.ptyProcess = loadNodePty(options.resources).spawn(shell, shellArgs(shell), {
|
|
13078
|
+
name: "xterm-256color",
|
|
13079
|
+
cols,
|
|
13080
|
+
rows,
|
|
13081
|
+
cwd: options.cwd,
|
|
13082
|
+
env: buildTerminalEnv(options.env, options.cwd)
|
|
13083
|
+
});
|
|
13084
|
+
this.registerCapabilityResponders();
|
|
13085
|
+
this.terminal.onTitleChange((next) => {
|
|
13086
|
+
this.pendingTitle = next.trim() || null;
|
|
13087
|
+
if (this.titleTimer) return;
|
|
13088
|
+
this.titleTimer = setTimeout(() => {
|
|
13089
|
+
this.titleTimer = null;
|
|
13090
|
+
if (this.pendingTitle === this.title) return;
|
|
13091
|
+
this.title = this.pendingTitle;
|
|
13092
|
+
for (const listener of [...this.titleListeners]) safely(() => listener(this.title));
|
|
13093
|
+
}, TITLE_DEBOUNCE_MS);
|
|
13094
|
+
});
|
|
13095
|
+
this.ptyProcess.onData((chunk) => {
|
|
13096
|
+
this.inputMode.feed(chunk);
|
|
13097
|
+
this.terminal.write(chunk, () => {
|
|
13098
|
+
for (const listener of [...this.outputListeners]) safely(() => listener(chunk));
|
|
13099
|
+
});
|
|
13100
|
+
});
|
|
13101
|
+
this.ptyProcess.onExit(({ exitCode, signal }) => {
|
|
13102
|
+
const finalize = () => {
|
|
13103
|
+
if (this.exitInfo) return;
|
|
13104
|
+
this.exitInfo = {
|
|
13105
|
+
exitCode,
|
|
13106
|
+
signal: signal ?? null,
|
|
13107
|
+
lastOutputLines: this.disposed ? [] : lastNonEmptyLines(this.terminal, EXIT_OUTPUT_LINE_LIMIT)
|
|
13108
|
+
};
|
|
13109
|
+
for (const listener of [...this.exitListeners]) safely(() => listener(this.exitInfo));
|
|
13110
|
+
};
|
|
13111
|
+
if (this.disposed) return finalize();
|
|
13112
|
+
this.terminal.write("", finalize);
|
|
13113
|
+
});
|
|
13114
|
+
}
|
|
13115
|
+
/** DA1 / DSR / OSC colour queries — see OSC_COLOR_QUERY_RESPONSES. */
|
|
13116
|
+
registerCapabilityResponders() {
|
|
13117
|
+
this.terminal.parser.registerCsiHandler({ final: "c" }, (params) => {
|
|
13118
|
+
if (params.length === 0 || params.length === 1 && params[0] === 0) {
|
|
13119
|
+
this.ptyProcess.write("\x1B[?62;4;22c");
|
|
13120
|
+
return true;
|
|
13121
|
+
}
|
|
13122
|
+
return false;
|
|
13123
|
+
});
|
|
13124
|
+
this.terminal.parser.registerCsiHandler({ final: "n" }, (params) => {
|
|
13125
|
+
if (params.length !== 1) return false;
|
|
13126
|
+
if (params[0] === 5) {
|
|
13127
|
+
this.ptyProcess.write("\x1B[0n");
|
|
13128
|
+
return true;
|
|
13129
|
+
}
|
|
13130
|
+
if (params[0] === 6) {
|
|
13131
|
+
const buffer = this.terminal.buffer.active;
|
|
13132
|
+
this.ptyProcess.write(`\x1B[${buffer.cursorY + 1};${buffer.cursorX + 1}R`);
|
|
13133
|
+
return true;
|
|
13134
|
+
}
|
|
13135
|
+
return false;
|
|
13136
|
+
});
|
|
13137
|
+
this.terminal.parser.registerCsiHandler({ prefix: "?", final: "n" }, (params) => {
|
|
13138
|
+
if (params.length !== 1 || params[0] !== 6) return false;
|
|
13139
|
+
const buffer = this.terminal.buffer.active;
|
|
13140
|
+
this.ptyProcess.write(`\x1B[?${buffer.cursorY + 1};${buffer.cursorX + 1}R`);
|
|
13141
|
+
return true;
|
|
13142
|
+
});
|
|
13143
|
+
for (const [code, response] of OSC_COLOR_QUERY_RESPONSES) {
|
|
13144
|
+
this.terminal.parser.registerOscHandler(code, (data) => {
|
|
13145
|
+
if (data.trim() !== "?") return false;
|
|
13146
|
+
this.ptyProcess.write(`\x1B]${code};${response}\x1B\\`);
|
|
13147
|
+
return true;
|
|
13148
|
+
});
|
|
13149
|
+
}
|
|
13150
|
+
}
|
|
13151
|
+
get alive() {
|
|
13152
|
+
return this.exitInfo === null && !this.disposed;
|
|
13153
|
+
}
|
|
13154
|
+
get exit() {
|
|
13155
|
+
return this.exitInfo;
|
|
13156
|
+
}
|
|
13157
|
+
info() {
|
|
13158
|
+
return {
|
|
13159
|
+
id: this.id,
|
|
13160
|
+
seq: this.seq,
|
|
13161
|
+
name: this.name,
|
|
13162
|
+
cwd: this.cwd,
|
|
13163
|
+
ownerUserId: this.ownerUserId,
|
|
13164
|
+
title: this.title,
|
|
13165
|
+
cols: this.sizeState.cols,
|
|
13166
|
+
rows: this.sizeState.rows,
|
|
13167
|
+
pid: this.ptyProcess.pid,
|
|
13168
|
+
createdAt: this.createdAt.toISOString(),
|
|
13169
|
+
alive: this.alive,
|
|
13170
|
+
exit: this.exitInfo
|
|
13171
|
+
};
|
|
13172
|
+
}
|
|
13173
|
+
rename(name) {
|
|
13174
|
+
const trimmed = name.trim();
|
|
13175
|
+
if (trimmed) this.name = trimmed;
|
|
13176
|
+
}
|
|
13177
|
+
write(data) {
|
|
13178
|
+
if (!this.alive) return;
|
|
13179
|
+
this.ptyProcess.write(data);
|
|
13180
|
+
}
|
|
13181
|
+
/**
|
|
13182
|
+
* Applies a size request under the claim/update ownership rule, resizing both the pty and
|
|
13183
|
+
* the daemon's emulator so snapshots keep matching what the shell laid out.
|
|
13184
|
+
*/
|
|
13185
|
+
resize(request) {
|
|
13186
|
+
const cols = clampDimension(request.cols, DEFAULT_COLS, 1e3);
|
|
13187
|
+
const rows = clampDimension(request.rows, DEFAULT_ROWS, 500);
|
|
13188
|
+
const decision = applyTerminalSize(this.sizeState, { ...request, cols, rows });
|
|
13189
|
+
this.sizeState = decision.state;
|
|
13190
|
+
if (!decision.apply || !this.alive) return false;
|
|
13191
|
+
this.terminal.resize(cols, rows);
|
|
13192
|
+
try {
|
|
13193
|
+
this.ptyProcess.resize(cols, rows);
|
|
13194
|
+
} catch {
|
|
13195
|
+
}
|
|
13196
|
+
return true;
|
|
13197
|
+
}
|
|
13198
|
+
releaseSize(connectionId) {
|
|
13199
|
+
this.sizeState = releaseTerminalSize(this.sizeState, connectionId);
|
|
13200
|
+
}
|
|
13201
|
+
get size() {
|
|
13202
|
+
return { cols: this.sizeState.cols, rows: this.sizeState.rows };
|
|
13203
|
+
}
|
|
13204
|
+
/** The attach payload: the whole screen as an ANSI stream (see snapshot.ts). */
|
|
13205
|
+
restoreStream(options = {}) {
|
|
13206
|
+
return renderRestoreAnsi(this.terminal, {
|
|
13207
|
+
...options,
|
|
13208
|
+
inputModePreamble: this.inputMode.preamble()
|
|
13209
|
+
});
|
|
13210
|
+
}
|
|
13211
|
+
capture(options = {}) {
|
|
13212
|
+
return captureLines(this.terminal, options);
|
|
13213
|
+
}
|
|
13214
|
+
onOutput(listener) {
|
|
13215
|
+
this.outputListeners.add(listener);
|
|
13216
|
+
return () => this.outputListeners.delete(listener);
|
|
13217
|
+
}
|
|
13218
|
+
onExit(listener) {
|
|
13219
|
+
this.exitListeners.add(listener);
|
|
13220
|
+
return () => this.exitListeners.delete(listener);
|
|
13221
|
+
}
|
|
13222
|
+
onTitleChange(listener) {
|
|
13223
|
+
this.titleListeners.add(listener);
|
|
13224
|
+
return () => this.titleListeners.delete(listener);
|
|
13225
|
+
}
|
|
13226
|
+
kill(signal) {
|
|
13227
|
+
if (!this.alive) return;
|
|
13228
|
+
try {
|
|
13229
|
+
this.ptyProcess.kill(signal);
|
|
13230
|
+
} catch {
|
|
13231
|
+
}
|
|
13232
|
+
}
|
|
13233
|
+
dispose() {
|
|
13234
|
+
if (this.disposed) return;
|
|
13235
|
+
if (this.titleTimer) clearTimeout(this.titleTimer);
|
|
13236
|
+
this.kill();
|
|
13237
|
+
this.disposed = true;
|
|
13238
|
+
if (process.platform !== "win32" && this.exitInfo === null) {
|
|
13239
|
+
try {
|
|
13240
|
+
this.ptyProcess.kill("SIGKILL");
|
|
13241
|
+
} catch {
|
|
13242
|
+
}
|
|
13243
|
+
}
|
|
13244
|
+
this.outputListeners.clear();
|
|
13245
|
+
this.exitListeners.clear();
|
|
13246
|
+
this.titleListeners.clear();
|
|
13247
|
+
this.terminal.dispose();
|
|
13248
|
+
}
|
|
13249
|
+
};
|
|
13250
|
+
function clampDimension(value, fallback, max) {
|
|
13251
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
13252
|
+
return Math.min(Math.max(Math.trunc(value), 1), max);
|
|
13253
|
+
}
|
|
13254
|
+
function lastNonEmptyLines(terminal, limit) {
|
|
13255
|
+
const buffer = terminal.buffer.active;
|
|
13256
|
+
const total = buffer.baseY + terminal.rows;
|
|
13257
|
+
const lines = [];
|
|
13258
|
+
for (let y = total - 1; y >= 0 && lines.length < limit; y -= 1) {
|
|
13259
|
+
const text = buffer.getLine(y)?.translateToString(true) ?? "";
|
|
13260
|
+
if (text.trim() === "" && lines.length === 0) continue;
|
|
13261
|
+
lines.push(text);
|
|
13262
|
+
}
|
|
13263
|
+
return lines.reverse();
|
|
13264
|
+
}
|
|
13265
|
+
function safely(fn) {
|
|
13266
|
+
try {
|
|
13267
|
+
fn();
|
|
13268
|
+
} catch {
|
|
13269
|
+
}
|
|
13270
|
+
}
|
|
13271
|
+
function expandHomePath(input) {
|
|
13272
|
+
if (input === "~") return os2.homedir();
|
|
13273
|
+
if (input.startsWith("~/")) return path19.join(os2.homedir(), input.slice(2));
|
|
13274
|
+
return input;
|
|
13275
|
+
}
|
|
13276
|
+
|
|
13277
|
+
// src/platform/terminal/manager.ts
|
|
13278
|
+
var EXITED_SESSION_GRACE_MS = 5 * 60 * 1e3;
|
|
13279
|
+
var MAX_TERMINALS_PER_USER = 12;
|
|
13280
|
+
var TerminalManager = class {
|
|
13281
|
+
/**
|
|
13282
|
+
* The kernel's resource registry, threaded to every session it creates: a native module
|
|
13283
|
+
* reaches the platform through it (pty-module.ts), and live ptys are registered in it so
|
|
13284
|
+
* a hot swap hands them to the next platform instead of killing them.
|
|
13285
|
+
*/
|
|
13286
|
+
constructor(resources, graceMs = EXITED_SESSION_GRACE_MS) {
|
|
13287
|
+
this.resources = resources;
|
|
13288
|
+
this.graceMs = graceMs;
|
|
13289
|
+
}
|
|
13290
|
+
resources;
|
|
13291
|
+
graceMs;
|
|
13292
|
+
sessions = /* @__PURE__ */ new Map();
|
|
13293
|
+
reapTimers = /* @__PURE__ */ new Map();
|
|
13294
|
+
async create(request) {
|
|
13295
|
+
const cwd = await resolveCwd(request.cwd);
|
|
13296
|
+
const owned = this.list(request.ownerUserId).filter((session2) => session2.alive);
|
|
13297
|
+
if (owned.length >= MAX_TERMINALS_PER_USER) {
|
|
13298
|
+
throw new HttpError(
|
|
13299
|
+
429,
|
|
13300
|
+
"terminal_limit_reached",
|
|
13301
|
+
`You already have ${MAX_TERMINALS_PER_USER} open terminals. Close one first.`
|
|
13302
|
+
);
|
|
13303
|
+
}
|
|
13304
|
+
let name = request.name;
|
|
13305
|
+
if (name === void 0) {
|
|
13306
|
+
const base = path20.basename(cwd) || "terminal";
|
|
13307
|
+
const taken = new Set(owned.map((session2) => session2.info().name));
|
|
13308
|
+
name = base;
|
|
13309
|
+
for (let n = 2; taken.has(name); n += 1) name = `${base} ${n}`;
|
|
13310
|
+
}
|
|
13311
|
+
const usedSeqs = new Set(owned.map((session2) => session2.seq));
|
|
13312
|
+
let seq = 1;
|
|
13313
|
+
while (usedSeqs.has(seq)) seq += 1;
|
|
13314
|
+
const options = {
|
|
13315
|
+
cwd,
|
|
13316
|
+
ownerUserId: request.ownerUserId,
|
|
13317
|
+
seq,
|
|
13318
|
+
name,
|
|
13319
|
+
resources: this.resources,
|
|
13320
|
+
...request.cols !== void 0 ? { cols: request.cols } : {},
|
|
13321
|
+
...request.rows !== void 0 ? { rows: request.rows } : {},
|
|
13322
|
+
...request.shell !== void 0 ? { shell: request.shell } : {}
|
|
13323
|
+
};
|
|
13324
|
+
let session;
|
|
13325
|
+
try {
|
|
13326
|
+
session = new TerminalSession(options);
|
|
13327
|
+
} catch (err) {
|
|
13328
|
+
const hint = spawnHelperHint();
|
|
13329
|
+
throw new HttpError(
|
|
13330
|
+
500,
|
|
13331
|
+
"terminal_spawn_failed",
|
|
13332
|
+
`Could not start a shell: ${err instanceof Error ? err.message : String(err)}` + (hint === null ? "" : ` (${hint})`)
|
|
13333
|
+
);
|
|
13334
|
+
}
|
|
13335
|
+
this.track(session);
|
|
13336
|
+
return session;
|
|
13337
|
+
}
|
|
13338
|
+
/**
|
|
13339
|
+
* Takes a session into this manager AND into the runtime's resource registry. The
|
|
13340
|
+
* registry is what makes a pty outlive a platform swap: it sits outside the reloadable
|
|
13341
|
+
* tree, so the shell keeps running and the next instance claims it back (see
|
|
13342
|
+
* hmr/resources.ts). The parked context carries only these ids.
|
|
13343
|
+
*/
|
|
13344
|
+
track(session) {
|
|
13345
|
+
this.sessions.set(session.id, session);
|
|
13346
|
+
session.onExit(() => this.scheduleReap(session.id));
|
|
13347
|
+
this.resources.register(resourceId(session.id), session, () => session.dispose());
|
|
13348
|
+
}
|
|
13349
|
+
/**
|
|
13350
|
+
* Reclaims the sessions a previous instance parked. An id the registry no longer knows
|
|
13351
|
+
* is simply dropped: its shell died with the process that owned it, and a manager
|
|
13352
|
+
* holding a handle to nothing would only fail later, further from the cause.
|
|
13353
|
+
*/
|
|
13354
|
+
adopt(ids) {
|
|
13355
|
+
for (const id of ids) {
|
|
13356
|
+
const session = this.resources.claim(resourceId(id));
|
|
13357
|
+
if (session === void 0) continue;
|
|
13358
|
+
this.sessions.set(session.id, session);
|
|
13359
|
+
session.onExit(() => this.scheduleReap(session.id));
|
|
13360
|
+
}
|
|
13361
|
+
}
|
|
13362
|
+
/** Handle ids for the parked context document — live sessions only. */
|
|
13363
|
+
handleIds() {
|
|
13364
|
+
return [...this.sessions.values()].filter((s) => s.alive).map((s) => s.id);
|
|
13365
|
+
}
|
|
13366
|
+
/** Looks up a session, enforcing ownership. Unknown and not-yours are both 404 — no probing. */
|
|
13367
|
+
require(id, userId) {
|
|
13368
|
+
const session = this.sessions.get(id);
|
|
13369
|
+
if (!session || session.ownerUserId !== userId) {
|
|
13370
|
+
throw new HttpError(404, "terminal_not_found", "Terminal does not exist or has been closed.");
|
|
13371
|
+
}
|
|
13372
|
+
return session;
|
|
13373
|
+
}
|
|
13374
|
+
get(id) {
|
|
13375
|
+
return this.sessions.get(id);
|
|
13376
|
+
}
|
|
13377
|
+
list(userId) {
|
|
13378
|
+
return [...this.sessions.values()].filter((session) => session.ownerUserId === userId).sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
|
13379
|
+
}
|
|
13380
|
+
listInfo(userId) {
|
|
13381
|
+
return this.list(userId).map((session) => session.info());
|
|
13382
|
+
}
|
|
13383
|
+
kill(id, userId) {
|
|
13384
|
+
const session = this.require(id, userId);
|
|
13385
|
+
session.kill();
|
|
13386
|
+
this.scheduleReap(id, 2e3);
|
|
13387
|
+
}
|
|
13388
|
+
/** Drops the session once nothing can usefully be read from it any more. */
|
|
13389
|
+
scheduleReap(id, delayMs = this.graceMs) {
|
|
13390
|
+
const existing = this.reapTimers.get(id);
|
|
13391
|
+
if (existing) clearTimeout(existing);
|
|
13392
|
+
const timer = setTimeout(() => {
|
|
13393
|
+
this.reapTimers.delete(id);
|
|
13394
|
+
this.sessions.get(id)?.dispose();
|
|
13395
|
+
this.sessions.delete(id);
|
|
13396
|
+
this.resources.release(resourceId(id));
|
|
13397
|
+
}, delayMs);
|
|
13398
|
+
timer.unref?.();
|
|
13399
|
+
this.reapTimers.set(id, timer);
|
|
13400
|
+
}
|
|
13401
|
+
/** Server shutdown: every pty is a child process and must not outlive us. */
|
|
13402
|
+
disposeAll() {
|
|
13403
|
+
for (const timer of this.reapTimers.values()) clearTimeout(timer);
|
|
13404
|
+
this.reapTimers.clear();
|
|
13405
|
+
for (const session of this.sessions.values()) session.dispose();
|
|
13406
|
+
this.sessions.clear();
|
|
13407
|
+
}
|
|
13408
|
+
};
|
|
13409
|
+
async function resolveCwd(input) {
|
|
13410
|
+
const expanded = expandHomePath((input ?? "").trim() || "~");
|
|
13411
|
+
if (!path20.isAbsolute(expanded)) {
|
|
13412
|
+
throw new HttpError(400, "cwd_not_absolute", `Working directory must be absolute: ${expanded}`);
|
|
13413
|
+
}
|
|
13414
|
+
let real;
|
|
13415
|
+
try {
|
|
13416
|
+
real = await fsp.realpath(expanded);
|
|
13417
|
+
} catch {
|
|
13418
|
+
throw new HttpError(400, "cwd_not_found", `Directory does not exist: ${expanded}`);
|
|
13419
|
+
}
|
|
13420
|
+
const stat = await fsp.stat(real);
|
|
13421
|
+
if (!stat.isDirectory()) {
|
|
13422
|
+
throw new HttpError(400, "cwd_not_a_dir", `Not a directory: ${real}`);
|
|
13423
|
+
}
|
|
13424
|
+
return real;
|
|
13425
|
+
}
|
|
13426
|
+
function resourceId(sessionId) {
|
|
13427
|
+
return `terminal:${sessionId}`;
|
|
13428
|
+
}
|
|
13429
|
+
|
|
13430
|
+
// src/platform/terminal/routes.ts
|
|
13431
|
+
import { Hono as Hono25 } from "hono";
|
|
13432
|
+
|
|
13433
|
+
// src/platform/hono-seam.ts
|
|
13434
|
+
var DECLINE_HEADER = "x-penguin-platform-decline";
|
|
13435
|
+
var DECLINE_TOKEN = crypto.randomUUID();
|
|
13436
|
+
function declined() {
|
|
13437
|
+
return new Response(null, { status: 404, headers: { [DECLINE_HEADER]: DECLINE_TOKEN } });
|
|
13438
|
+
}
|
|
13439
|
+
function seamHttp(app2) {
|
|
13440
|
+
return async (request) => {
|
|
13441
|
+
const response = await app2.fetch(request);
|
|
13442
|
+
return response.headers.get(DECLINE_HEADER) === DECLINE_TOKEN ? null : response;
|
|
13443
|
+
};
|
|
13444
|
+
}
|
|
13445
|
+
|
|
13446
|
+
// src/platform/terminal/routes.ts
|
|
13447
|
+
var KEY_TOKENS = {
|
|
13448
|
+
Enter: "\r",
|
|
13449
|
+
Tab: " ",
|
|
13450
|
+
Escape: "\x1B",
|
|
13451
|
+
Backspace: "\x7F",
|
|
13452
|
+
Space: " ",
|
|
13453
|
+
Up: "\x1B[A",
|
|
13454
|
+
Down: "\x1B[B",
|
|
13455
|
+
Right: "\x1B[C",
|
|
13456
|
+
Left: "\x1B[D",
|
|
13457
|
+
Home: "\x1B[H",
|
|
13458
|
+
End: "\x1B[F",
|
|
13459
|
+
PageUp: "\x1B[5~",
|
|
13460
|
+
PageDown: "\x1B[6~",
|
|
13461
|
+
Delete: "\x1B[3~"
|
|
13462
|
+
};
|
|
13463
|
+
function resolveKeys(keys, literal) {
|
|
13464
|
+
if (literal) return keys;
|
|
13465
|
+
if (keys in KEY_TOKENS) return KEY_TOKENS[keys];
|
|
13466
|
+
const ctrl = /^C-([a-z@[\]\\^_])$/i.exec(keys);
|
|
13467
|
+
if (ctrl) {
|
|
13468
|
+
const ch = ctrl[1].toUpperCase();
|
|
13469
|
+
return String.fromCharCode(ch.charCodeAt(0) & 31);
|
|
13470
|
+
}
|
|
13471
|
+
return keys;
|
|
13472
|
+
}
|
|
13473
|
+
function optionalInt(value, field) {
|
|
13474
|
+
if (value === void 0 || value === null) return void 0;
|
|
13475
|
+
if (!Number.isInteger(value)) throw badRequest(`${field} must be an integer.`);
|
|
13476
|
+
return value;
|
|
13477
|
+
}
|
|
13478
|
+
function json(body, status = 200) {
|
|
13479
|
+
return new Response(JSON.stringify(body), {
|
|
13480
|
+
status,
|
|
13481
|
+
headers: { "content-type": "application/json; charset=UTF-8" }
|
|
13482
|
+
});
|
|
13483
|
+
}
|
|
13484
|
+
function errorResponse(err) {
|
|
13485
|
+
if (err instanceof HttpError) {
|
|
13486
|
+
return json({ error: { code: err.code, message: err.message } }, err.status);
|
|
13487
|
+
}
|
|
13488
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
13489
|
+
return json({ error: { code: "internal", message } }, 500);
|
|
13490
|
+
}
|
|
13491
|
+
async function readBody(request) {
|
|
13492
|
+
try {
|
|
13493
|
+
return await request.json();
|
|
13494
|
+
} catch {
|
|
13495
|
+
return {};
|
|
13496
|
+
}
|
|
13497
|
+
}
|
|
13498
|
+
function terminalHttp(manager, identity) {
|
|
13499
|
+
const app2 = new Hono25();
|
|
13500
|
+
app2.notFound(() => declined());
|
|
13501
|
+
app2.onError((err) => errorResponse(err));
|
|
13502
|
+
const gate = async (c, next) => {
|
|
13503
|
+
const user = await identity(c.req.raw);
|
|
13504
|
+
if (user === null) {
|
|
13505
|
+
return json({ error: { code: "unauthorized", message: "Not signed in." } }, 401);
|
|
13506
|
+
}
|
|
13507
|
+
c.set("user", user);
|
|
13508
|
+
await next();
|
|
13509
|
+
};
|
|
13510
|
+
app2.get("/api/terminals", gate, (c) => json({ terminals: manager.listInfo(c.var.user.userId) }));
|
|
13511
|
+
app2.post(
|
|
13512
|
+
"/api/terminals",
|
|
13513
|
+
gate,
|
|
13514
|
+
async (c) => json(await create2(c.req.raw, manager, c.var.user.userId), 201)
|
|
13515
|
+
);
|
|
13516
|
+
app2.get(
|
|
13517
|
+
"/api/terminals/:id",
|
|
13518
|
+
gate,
|
|
13519
|
+
(c) => json(manager.require(c.req.param("id"), c.var.user.userId).info())
|
|
13520
|
+
);
|
|
13521
|
+
app2.delete("/api/terminals/:id", gate, (c) => {
|
|
13522
|
+
manager.kill(c.req.param("id"), c.var.user.userId);
|
|
13523
|
+
return new Response(null, { status: 204 });
|
|
13524
|
+
});
|
|
13525
|
+
app2.get("/api/terminals/:id/capture", gate, (c) => {
|
|
13526
|
+
const session = manager.require(c.req.param("id"), c.var.user.userId);
|
|
13527
|
+
const start = c.req.query("start");
|
|
13528
|
+
const end = c.req.query("end");
|
|
13529
|
+
return json(
|
|
13530
|
+
session.capture({
|
|
13531
|
+
...start !== void 0 ? { start: Number.parseInt(start, 10) } : {},
|
|
13532
|
+
...end !== void 0 ? { end: Number.parseInt(end, 10) } : {}
|
|
13533
|
+
})
|
|
13534
|
+
);
|
|
13535
|
+
});
|
|
13536
|
+
app2.post("/api/terminals/:id/keys", gate, async (c) => {
|
|
13537
|
+
const session = manager.require(c.req.param("id"), c.var.user.userId);
|
|
13538
|
+
const body = await readBody(c.req.raw);
|
|
13539
|
+
if (typeof body.keys !== "string") throw badRequest("keys must be a string.");
|
|
13540
|
+
if (!session.alive) {
|
|
13541
|
+
throw new HttpError(409, "terminal_exited", "This terminal's shell has exited.");
|
|
13542
|
+
}
|
|
13543
|
+
session.write(resolveKeys(body.keys, body.literal === true));
|
|
13544
|
+
return json({ ok: true });
|
|
13545
|
+
});
|
|
13546
|
+
return seamHttp(app2);
|
|
13547
|
+
}
|
|
13548
|
+
async function create2(request, manager, ownerUserId) {
|
|
13549
|
+
const body = await readBody(request);
|
|
13550
|
+
const cwd = typeof body.cwd === "string" && body.cwd.trim() ? body.cwd.trim() : "~";
|
|
13551
|
+
const name = typeof body.name === "string" ? body.name : void 0;
|
|
13552
|
+
const shell = typeof body.shell === "string" && body.shell.trim() ? body.shell.trim() : void 0;
|
|
13553
|
+
const session = await manager.create({
|
|
13554
|
+
cwd,
|
|
13555
|
+
ownerUserId,
|
|
13556
|
+
...name !== void 0 ? { name } : {},
|
|
13557
|
+
...shell !== void 0 ? { shell } : {},
|
|
13558
|
+
...optionalInt(body.cols, "cols") !== void 0 ? { cols: body.cols } : {},
|
|
13559
|
+
...optionalInt(body.rows, "rows") !== void 0 ? { rows: body.rows } : {}
|
|
13560
|
+
});
|
|
13561
|
+
return session.info();
|
|
13562
|
+
}
|
|
13563
|
+
|
|
13564
|
+
// src/platform/terminal/stream.ts
|
|
13565
|
+
import { randomUUID as randomUUID5 } from "crypto";
|
|
13566
|
+
|
|
13567
|
+
// src/platform/terminal/frames.ts
|
|
13568
|
+
var TerminalStreamOpcode = {
|
|
13569
|
+
/** Server -> client: terminal output bytes. */
|
|
13570
|
+
Output: 1,
|
|
13571
|
+
/** Client -> server: keyboard/paste input bytes. */
|
|
13572
|
+
Input: 2,
|
|
13573
|
+
/** Client -> server: viewport size, JSON `{cols, rows, intent}`. */
|
|
13574
|
+
Resize: 3,
|
|
13575
|
+
/** Server -> client: full-screen restore stream (ANSI), sent once on attach. */
|
|
13576
|
+
Restore: 5,
|
|
13577
|
+
/** Server -> client: the pty exited, JSON `{exitCode, signal}`. */
|
|
13578
|
+
Exit: 6
|
|
13579
|
+
};
|
|
13580
|
+
var OPCODES = new Set(Object.values(TerminalStreamOpcode));
|
|
13581
|
+
var encoder = new TextEncoder();
|
|
13582
|
+
var decoder = new TextDecoder();
|
|
13583
|
+
function encodeTerminalFrame(input) {
|
|
13584
|
+
const payload = typeof input.payload === "string" ? encoder.encode(input.payload) : input.payload ?? new Uint8Array(0);
|
|
13585
|
+
const bytes = new Uint8Array(2 + payload.byteLength);
|
|
13586
|
+
bytes[0] = input.opcode;
|
|
13587
|
+
bytes[1] = (input.slot ?? 0) & 255;
|
|
13588
|
+
bytes.set(payload, 2);
|
|
13589
|
+
return bytes;
|
|
13590
|
+
}
|
|
13591
|
+
function decodeTerminalFrame(bytes) {
|
|
13592
|
+
if (bytes.byteLength < 2) return null;
|
|
13593
|
+
const opcode = bytes[0];
|
|
13594
|
+
if (!OPCODES.has(opcode)) return null;
|
|
13595
|
+
return {
|
|
13596
|
+
opcode,
|
|
13597
|
+
slot: bytes[1],
|
|
13598
|
+
payload: bytes.subarray(2)
|
|
13599
|
+
};
|
|
13600
|
+
}
|
|
13601
|
+
function framePayloadText(frame) {
|
|
13602
|
+
return decoder.decode(frame.payload);
|
|
13603
|
+
}
|
|
13604
|
+
function parseResizePayload(frame) {
|
|
13605
|
+
let parsed;
|
|
13606
|
+
try {
|
|
13607
|
+
parsed = JSON.parse(framePayloadText(frame));
|
|
13608
|
+
} catch {
|
|
13609
|
+
return null;
|
|
13610
|
+
}
|
|
13611
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
13612
|
+
const { cols, rows, intent } = parsed;
|
|
13613
|
+
if (!Number.isInteger(cols) || !Number.isInteger(rows)) return null;
|
|
13614
|
+
if (cols < 1 || rows < 1) return null;
|
|
13615
|
+
return {
|
|
13616
|
+
cols: Math.min(cols, 1e3),
|
|
13617
|
+
rows: Math.min(rows, 500),
|
|
13618
|
+
intent: intent === "update" ? "update" : "claim"
|
|
13619
|
+
};
|
|
13620
|
+
}
|
|
13621
|
+
|
|
13622
|
+
// src/platform/terminal/output-coalescer.ts
|
|
13623
|
+
var DEFAULT_COALESCE_WINDOW_MS = 5;
|
|
13624
|
+
var TerminalOutputCoalescer = class {
|
|
13625
|
+
constructor(flushFn, windowMs = DEFAULT_COALESCE_WINDOW_MS, now = () => Date.now()) {
|
|
13626
|
+
this.flushFn = flushFn;
|
|
13627
|
+
this.windowMs = windowMs;
|
|
13628
|
+
this.now = now;
|
|
13629
|
+
}
|
|
13630
|
+
flushFn;
|
|
13631
|
+
windowMs;
|
|
13632
|
+
now;
|
|
13633
|
+
pending = "";
|
|
13634
|
+
timer = null;
|
|
13635
|
+
lastFlushAt = 0;
|
|
13636
|
+
push(data) {
|
|
13637
|
+
if (data.length === 0) return;
|
|
13638
|
+
this.pending += data;
|
|
13639
|
+
if (this.timer !== null) return;
|
|
13640
|
+
const elapsed = this.now() - this.lastFlushAt;
|
|
13641
|
+
if (elapsed >= this.windowMs) {
|
|
13642
|
+
this.flush();
|
|
13643
|
+
return;
|
|
13644
|
+
}
|
|
13645
|
+
this.timer = setTimeout(() => {
|
|
13646
|
+
this.timer = null;
|
|
13647
|
+
this.flush();
|
|
13648
|
+
}, this.windowMs - elapsed);
|
|
13649
|
+
}
|
|
13650
|
+
/**
|
|
13651
|
+
* Flushes anything buffered. Callers must invoke this before sending an out-of-band
|
|
13652
|
+
* message (restore, exit) so ordering with the output stream is preserved.
|
|
13653
|
+
*/
|
|
13654
|
+
flush() {
|
|
13655
|
+
if (this.timer !== null) {
|
|
13656
|
+
clearTimeout(this.timer);
|
|
13657
|
+
this.timer = null;
|
|
13658
|
+
}
|
|
13659
|
+
this.lastFlushAt = this.now();
|
|
13660
|
+
if (this.pending.length === 0) return;
|
|
13661
|
+
const data = this.pending;
|
|
13662
|
+
this.pending = "";
|
|
13663
|
+
this.flushFn(data);
|
|
13664
|
+
}
|
|
13665
|
+
dispose() {
|
|
13666
|
+
if (this.timer !== null) {
|
|
13667
|
+
clearTimeout(this.timer);
|
|
13668
|
+
this.timer = null;
|
|
13669
|
+
}
|
|
13670
|
+
this.pending = "";
|
|
13671
|
+
}
|
|
13672
|
+
};
|
|
13673
|
+
|
|
13674
|
+
// src/platform/terminal/stream.ts
|
|
13675
|
+
var BACKPRESSURE_HIGH_WATER = 1024 * 1024;
|
|
13676
|
+
var BACKPRESSURE_LOW_WATER = 64 * 1024;
|
|
13677
|
+
var BACKPRESSURE_POLL_MS = 250;
|
|
13678
|
+
function bindTerminalStream(ws, session, url, log) {
|
|
13679
|
+
const connectionId = randomUUID5();
|
|
13680
|
+
ws.binaryType = "nodebuffer";
|
|
13681
|
+
const send = (bytes) => {
|
|
13682
|
+
if (ws.readyState === ws.OPEN) ws.send(bytes);
|
|
13683
|
+
};
|
|
13684
|
+
let desynced = false;
|
|
13685
|
+
let resyncTimer = null;
|
|
13686
|
+
const stopResyncPoll = () => {
|
|
13687
|
+
if (resyncTimer) clearInterval(resyncTimer);
|
|
13688
|
+
resyncTimer = null;
|
|
13689
|
+
};
|
|
13690
|
+
const sendOutput = (data) => {
|
|
13691
|
+
if (desynced) return;
|
|
13692
|
+
send(encodeTerminalFrame({ opcode: TerminalStreamOpcode.Output, payload: data }));
|
|
13693
|
+
if (ws.bufferedAmount <= BACKPRESSURE_HIGH_WATER) return;
|
|
13694
|
+
desynced = true;
|
|
13695
|
+
log(`[terminal] stream ${session.id}: viewer ${ws.bufferedAmount}B behind, pausing for resync`);
|
|
13696
|
+
resyncTimer = setInterval(() => {
|
|
13697
|
+
if (ws.readyState !== ws.OPEN) return stopResyncPoll();
|
|
13698
|
+
if (ws.bufferedAmount > BACKPRESSURE_LOW_WATER) return;
|
|
13699
|
+
stopResyncPoll();
|
|
13700
|
+
desynced = false;
|
|
13701
|
+
try {
|
|
13702
|
+
send(
|
|
13703
|
+
encodeTerminalFrame({
|
|
13704
|
+
opcode: TerminalStreamOpcode.Restore,
|
|
13705
|
+
payload: session.restoreStream()
|
|
13706
|
+
})
|
|
13707
|
+
);
|
|
13708
|
+
} catch {
|
|
13709
|
+
}
|
|
13710
|
+
}, BACKPRESSURE_POLL_MS);
|
|
13711
|
+
};
|
|
13712
|
+
let restored = false;
|
|
13713
|
+
let preRestore = "";
|
|
13714
|
+
const coalescer = new TerminalOutputCoalescer(sendOutput);
|
|
13715
|
+
const unsubscribeOutput = session.onOutput((data) => {
|
|
13716
|
+
if (!restored) {
|
|
13717
|
+
preRestore += data;
|
|
13718
|
+
return;
|
|
13719
|
+
}
|
|
13720
|
+
coalescer.push(data);
|
|
13721
|
+
});
|
|
13722
|
+
const unsubscribeExit = session.onExit((info) => {
|
|
13723
|
+
coalescer.flush();
|
|
13724
|
+
send(
|
|
13725
|
+
encodeTerminalFrame({
|
|
13726
|
+
opcode: TerminalStreamOpcode.Exit,
|
|
13727
|
+
payload: JSON.stringify({ exitCode: info.exitCode, signal: info.signal })
|
|
13728
|
+
})
|
|
13729
|
+
);
|
|
13730
|
+
});
|
|
13731
|
+
const cols = Number.parseInt(url.searchParams.get("cols") ?? "", 10);
|
|
13732
|
+
const rows = Number.parseInt(url.searchParams.get("rows") ?? "", 10);
|
|
13733
|
+
if (Number.isInteger(cols) && Number.isInteger(rows)) {
|
|
13734
|
+
session.resize({ connectionId, cols, rows, intent: "claim" });
|
|
13735
|
+
}
|
|
13736
|
+
send(
|
|
13737
|
+
encodeTerminalFrame({
|
|
13738
|
+
opcode: TerminalStreamOpcode.Restore,
|
|
13739
|
+
payload: session.restoreStream()
|
|
13740
|
+
})
|
|
13741
|
+
);
|
|
13742
|
+
restored = true;
|
|
13743
|
+
if (preRestore) {
|
|
13744
|
+
coalescer.push(preRestore);
|
|
13745
|
+
preRestore = "";
|
|
13746
|
+
}
|
|
13747
|
+
if (!session.alive && session.exit) {
|
|
13748
|
+
send(
|
|
13749
|
+
encodeTerminalFrame({
|
|
13750
|
+
opcode: TerminalStreamOpcode.Exit,
|
|
13751
|
+
payload: JSON.stringify({ exitCode: session.exit.exitCode, signal: session.exit.signal })
|
|
13752
|
+
})
|
|
13753
|
+
);
|
|
13754
|
+
}
|
|
13755
|
+
ws.on("message", (raw, isBinary) => {
|
|
13756
|
+
if (!isBinary) return;
|
|
13757
|
+
const frame = decodeTerminalFrame(new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength));
|
|
13758
|
+
if (!frame) return;
|
|
13759
|
+
switch (frame.opcode) {
|
|
13760
|
+
case TerminalStreamOpcode.Input:
|
|
13761
|
+
session.write(framePayloadText(frame));
|
|
13762
|
+
break;
|
|
13763
|
+
case TerminalStreamOpcode.Resize: {
|
|
13764
|
+
const size = parseResizePayload(frame);
|
|
13765
|
+
if (size) session.resize({ connectionId, ...size });
|
|
13766
|
+
break;
|
|
13767
|
+
}
|
|
13768
|
+
default:
|
|
13769
|
+
break;
|
|
13770
|
+
}
|
|
13771
|
+
});
|
|
13772
|
+
const teardown = () => {
|
|
13773
|
+
unsubscribeOutput();
|
|
13774
|
+
unsubscribeExit();
|
|
13775
|
+
stopResyncPoll();
|
|
13776
|
+
coalescer.dispose();
|
|
13777
|
+
session.releaseSize(connectionId);
|
|
13778
|
+
};
|
|
13779
|
+
ws.on("close", teardown);
|
|
13780
|
+
ws.on("error", (err) => {
|
|
13781
|
+
log(`[terminal] stream ${session.id} socket error: ${err.message}`);
|
|
13782
|
+
teardown();
|
|
13783
|
+
});
|
|
13784
|
+
}
|
|
13785
|
+
|
|
13786
|
+
// src/platform/platform.ts
|
|
11760
13787
|
var PlatformIface = defineIface({
|
|
11761
13788
|
name: "platform",
|
|
11762
13789
|
version: 1,
|
|
11763
|
-
context: schema(type({ motd: "string" })),
|
|
11764
|
-
methods: ["park", "info"]
|
|
13790
|
+
context: schema(type({ motd: "string", "terminals?": "string[]" })),
|
|
13791
|
+
methods: ["park", "info", "http", "terminals", "attachStream"]
|
|
11765
13792
|
});
|
|
11766
13793
|
var platformImpl = {
|
|
11767
|
-
create(
|
|
13794
|
+
create(ctx, context) {
|
|
13795
|
+
const terminals = new TerminalManager(ctx.resources);
|
|
13796
|
+
terminals.adopt(context.terminals ?? []);
|
|
13797
|
+
const http = terminalHttp(terminals, identityFrom(ctx.resources));
|
|
11768
13798
|
return {
|
|
11769
|
-
|
|
13799
|
+
// Deliberately NOT disposing the terminals here (no ctx.effect): the shells are
|
|
13800
|
+
// resources, and outliving a swap is the whole point. Process exit sweeps them
|
|
13801
|
+
// through the registry's own disposers.
|
|
13802
|
+
park: () => ({ motd: context.motd, terminals: terminals.handleIds() }),
|
|
11770
13803
|
info: () => ({
|
|
11771
13804
|
impl: "packaged",
|
|
11772
13805
|
ifaceVersion: PlatformIface.version,
|
|
11773
|
-
motd: context.motd
|
|
11774
|
-
|
|
13806
|
+
motd: context.motd,
|
|
13807
|
+
terminals: terminals.handleIds().length
|
|
13808
|
+
}),
|
|
13809
|
+
http,
|
|
13810
|
+
terminals: () => terminals,
|
|
13811
|
+
attachStream: (ws, session, url, log) => bindTerminalStream(ws, session, url, log)
|
|
11775
13812
|
};
|
|
11776
13813
|
}
|
|
11777
13814
|
};
|
|
@@ -11783,13 +13820,15 @@ var packagedPlatform = {
|
|
|
11783
13820
|
};
|
|
11784
13821
|
|
|
11785
13822
|
// src/hmr/host.ts
|
|
13823
|
+
var ASSETS_RESOURCE_ID = "runtime:assets-dir";
|
|
13824
|
+
var MATERIALIZED = ".materialized";
|
|
11786
13825
|
var STORE_KEEP = 2;
|
|
11787
13826
|
var HmrHost = class {
|
|
11788
13827
|
constructor(root) {
|
|
11789
13828
|
this.root = root;
|
|
11790
|
-
this.hmrDir =
|
|
11791
|
-
this.storeDir =
|
|
11792
|
-
this.manifestPath =
|
|
13829
|
+
this.hmrDir = path21.join(root, "hmr");
|
|
13830
|
+
this.storeDir = path21.join(this.hmrDir, "store");
|
|
13831
|
+
this.manifestPath = path21.join(this.hmrDir, "harness.json");
|
|
11793
13832
|
}
|
|
11794
13833
|
root;
|
|
11795
13834
|
resources = new HotResources();
|
|
@@ -11875,7 +13914,7 @@ var HmrHost = class {
|
|
|
11875
13914
|
async restore() {
|
|
11876
13915
|
let manifest;
|
|
11877
13916
|
try {
|
|
11878
|
-
manifest = JSON.parse(await
|
|
13917
|
+
manifest = JSON.parse(await fsp2.readFile(this.manifestPath, "utf8"));
|
|
11879
13918
|
} catch {
|
|
11880
13919
|
return;
|
|
11881
13920
|
}
|
|
@@ -11892,12 +13931,19 @@ var HmrHost = class {
|
|
|
11892
13931
|
if (manifest.web === void 0) {
|
|
11893
13932
|
throw new Error("harness.json has no `web` entry");
|
|
11894
13933
|
}
|
|
11895
|
-
const cliPath =
|
|
11896
|
-
if (!
|
|
13934
|
+
const cliPath = path21.join(this.hmrDir, manifest.cli.bundle);
|
|
13935
|
+
if (!fs22.existsSync(cliPath)) {
|
|
11897
13936
|
throw new Error(`cli bundle '${manifest.cli.bundle}' does not exist`);
|
|
11898
13937
|
}
|
|
11899
|
-
|
|
11900
|
-
|
|
13938
|
+
if (manifest.assets !== void 0) {
|
|
13939
|
+
const assetsDir = path21.join(this.hmrDir, manifest.assets.dir);
|
|
13940
|
+
if (!fs22.existsSync(assetsDir)) {
|
|
13941
|
+
throw new Error(`assets dir '${manifest.assets.dir}' does not exist`);
|
|
13942
|
+
}
|
|
13943
|
+
this.publishAssets(assetsDir);
|
|
13944
|
+
}
|
|
13945
|
+
const bundle = await this.importBundleFile(path21.join(this.hmrDir, manifest.platform.bundle));
|
|
13946
|
+
const gz = await fsp2.readFile(path21.join(this.hmrDir, manifest.web.manifest));
|
|
11901
13947
|
const webMem = filesMapFromGzip(gz);
|
|
11902
13948
|
const instance = await boot(
|
|
11903
13949
|
bundle.impl,
|
|
@@ -11936,13 +13982,23 @@ var HmrHost = class {
|
|
|
11936
13982
|
const platformPath = await this.writeInlinePlatformBundle(target.platform);
|
|
11937
13983
|
const bundle = await this.importBundleFile(platformPath);
|
|
11938
13984
|
const source = target.source ?? null;
|
|
11939
|
-
const
|
|
11940
|
-
|
|
11941
|
-
|
|
11942
|
-
|
|
11943
|
-
|
|
11944
|
-
|
|
13985
|
+
const previousAssets = this.resources.claim(ASSETS_RESOURCE_ID) ?? null;
|
|
13986
|
+
const assetsDir = target.assets ? await this.materializeAssets(target.assets) : null;
|
|
13987
|
+
if (assetsDir !== null) this.publishAssets(assetsDir);
|
|
13988
|
+
let result;
|
|
13989
|
+
try {
|
|
13990
|
+
result = await upgrade({
|
|
13991
|
+
current: current2,
|
|
13992
|
+
impl: bundle.impl,
|
|
13993
|
+
iface: bundle.iface,
|
|
13994
|
+
resources: this.resources
|
|
13995
|
+
});
|
|
13996
|
+
} catch (err) {
|
|
13997
|
+
this.publishAssets(previousAssets);
|
|
13998
|
+
throw err;
|
|
13999
|
+
}
|
|
11945
14000
|
if (result.status === "blocked") {
|
|
14001
|
+
this.publishAssets(previousAssets);
|
|
11946
14002
|
return {
|
|
11947
14003
|
status: "blocked",
|
|
11948
14004
|
dropped: result.dropped,
|
|
@@ -11959,7 +14015,8 @@ var HmrHost = class {
|
|
|
11959
14015
|
target.platform,
|
|
11960
14016
|
target.cli,
|
|
11961
14017
|
gz,
|
|
11962
|
-
digest2.slice(0, 16)
|
|
14018
|
+
digest2.slice(0, 16),
|
|
14019
|
+
assetsDir
|
|
11963
14020
|
);
|
|
11964
14021
|
return {
|
|
11965
14022
|
status: "ok",
|
|
@@ -11972,8 +14029,8 @@ var HmrHost = class {
|
|
|
11972
14029
|
}
|
|
11973
14030
|
/** Layer (a) core: import one JS file, cache-busted so re-imports load fresh code. */
|
|
11974
14031
|
async importBundleFile(file) {
|
|
11975
|
-
const resolved =
|
|
11976
|
-
if (!
|
|
14032
|
+
const resolved = path21.resolve(file);
|
|
14033
|
+
if (!fs22.existsSync(resolved)) throw new Error(`bundle file '${file}' does not exist`);
|
|
11977
14034
|
const url = `${pathToFileURL(resolved).href}?v=${Date.now()}`;
|
|
11978
14035
|
const mod = await import(url);
|
|
11979
14036
|
if (mod.hotPlatform === void 0) {
|
|
@@ -11995,10 +14052,10 @@ var HmrHost = class {
|
|
|
11995
14052
|
* store.
|
|
11996
14053
|
*/
|
|
11997
14054
|
async writeInlinePlatformBundle(content) {
|
|
11998
|
-
const dir =
|
|
11999
|
-
await
|
|
12000
|
-
const file =
|
|
12001
|
-
await
|
|
14055
|
+
const dir = path21.join(this.hmrDir, "uploads");
|
|
14056
|
+
await fsp2.mkdir(dir, { recursive: true });
|
|
14057
|
+
const file = path21.join(dir, `platform-${sha1(content).slice(0, 16)}.mjs`);
|
|
14058
|
+
await fsp2.writeFile(file, content, "utf8");
|
|
12002
14059
|
return file;
|
|
12003
14060
|
}
|
|
12004
14061
|
// -- Web platform (the frontend package's built dist) ---------------------
|
|
@@ -12028,23 +14085,59 @@ var HmrHost = class {
|
|
|
12028
14085
|
* (`persisted` in UpgradeOutcome) so a live swap that could not be written to disk
|
|
12029
14086
|
* is visibly at risk of reverting on the next restart, rather than silently ok.
|
|
12030
14087
|
*/
|
|
12031
|
-
|
|
14088
|
+
/**
|
|
14089
|
+
* Unpacks a push's assets under store/assets/<sha>/, content-addressed like every other
|
|
14090
|
+
* artifact so an unchanged set reuses its directory. Modes are restored from the push's
|
|
14091
|
+
* `exec` list: an asset arrives as base64 with no mode of its own, and a helper binary
|
|
14092
|
+
* without its exec bit is exactly the failure this whole path exists to avoid.
|
|
14093
|
+
*
|
|
14094
|
+
* An identical set is NOT re-written. That is a correctness requirement, not a saving:
|
|
14095
|
+
* these assets are native modules, and on Windows the copies from the last push are
|
|
14096
|
+
* mapped into this very process — reopening one for writing fails with EBUSY and takes
|
|
14097
|
+
* the whole upgrade down with it. The directory name already proves the content
|
|
14098
|
+
* matches; MATERIALIZED, written last, is what proves the directory is complete, so a
|
|
14099
|
+
* push interrupted halfway is repaired rather than trusted.
|
|
14100
|
+
*/
|
|
14101
|
+
async materializeAssets(assets) {
|
|
14102
|
+
const sha = filesDigest(assets.files).slice(0, 16);
|
|
14103
|
+
const dir = path21.join(this.storeDir, "assets", sha);
|
|
14104
|
+
const marker = path21.join(dir, MATERIALIZED);
|
|
14105
|
+
if (fs22.existsSync(marker)) return dir;
|
|
14106
|
+
const exec = new Set(assets.exec ?? []);
|
|
14107
|
+
for (const [rel, b64] of Object.entries(assets.files)) {
|
|
14108
|
+
if (!isSafeRelPath(rel)) throw new Error(`unsafe path in assets manifest: ${rel}`);
|
|
14109
|
+
const file = path21.join(dir, rel);
|
|
14110
|
+
const content = Buffer.from(b64, "base64");
|
|
14111
|
+
await fsp2.mkdir(path21.dirname(file), { recursive: true });
|
|
14112
|
+
if (!await sameFileContent(file, content)) await fsp2.writeFile(file, content);
|
|
14113
|
+
await fsp2.chmod(file, exec.has(rel) ? 493 : 420);
|
|
14114
|
+
}
|
|
14115
|
+
await fsp2.writeFile(marker, sha);
|
|
14116
|
+
return dir;
|
|
14117
|
+
}
|
|
14118
|
+
/** Points the registry at this version's assets (or clears it when a push has none). */
|
|
14119
|
+
publishAssets(dir) {
|
|
14120
|
+
if (dir === null) this.resources.release(ASSETS_RESOURCE_ID);
|
|
14121
|
+
else this.resources.register(ASSETS_RESOURCE_ID, dir);
|
|
14122
|
+
}
|
|
14123
|
+
async persistVersion(platformContent, cliContent, webGz, webSha, assetsDir) {
|
|
12032
14124
|
try {
|
|
12033
14125
|
const platformSha = sha1(platformContent).slice(0, 16);
|
|
12034
|
-
const platformDir =
|
|
12035
|
-
await
|
|
12036
|
-
await
|
|
14126
|
+
const platformDir = path21.join(this.storeDir, "platform");
|
|
14127
|
+
await fsp2.mkdir(platformDir, { recursive: true });
|
|
14128
|
+
await fsp2.writeFile(path21.join(platformDir, `${platformSha}.mjs`), platformContent, "utf8");
|
|
12037
14129
|
const cliSha = sha1(cliContent).slice(0, 16);
|
|
12038
|
-
const cliDir =
|
|
12039
|
-
await
|
|
12040
|
-
await
|
|
12041
|
-
const webDir =
|
|
12042
|
-
await
|
|
12043
|
-
await
|
|
14130
|
+
const cliDir = path21.join(this.storeDir, "cli");
|
|
14131
|
+
await fsp2.mkdir(cliDir, { recursive: true });
|
|
14132
|
+
await fsp2.writeFile(path21.join(cliDir, `${cliSha}.mjs`), cliContent, "utf8");
|
|
14133
|
+
const webDir = path21.join(this.storeDir, "web");
|
|
14134
|
+
await fsp2.mkdir(webDir, { recursive: true });
|
|
14135
|
+
await fsp2.writeFile(path21.join(webDir, `${webSha}.webz`), webGz);
|
|
12044
14136
|
await this.commitManifest(() => ({
|
|
12045
14137
|
platform: { bundle: `store/platform/${platformSha}.mjs` },
|
|
12046
14138
|
cli: { bundle: `store/cli/${cliSha}.mjs` },
|
|
12047
|
-
web: { manifest: `store/web/${webSha}.webz` }
|
|
14139
|
+
web: { manifest: `store/web/${webSha}.webz` },
|
|
14140
|
+
...assetsDir === null ? {} : { assets: { dir: path21.relative(this.hmrDir, assetsDir).split(path21.sep).join("/") } }
|
|
12048
14141
|
}));
|
|
12049
14142
|
return true;
|
|
12050
14143
|
} catch (err) {
|
|
@@ -12054,11 +14147,11 @@ var HmrHost = class {
|
|
|
12054
14147
|
}
|
|
12055
14148
|
/** Writes and atomically replaces harness.json (the single commit point for a whole version). */
|
|
12056
14149
|
async commitManifest(next) {
|
|
12057
|
-
await
|
|
14150
|
+
await fsp2.mkdir(this.hmrDir, { recursive: true });
|
|
12058
14151
|
const manifest = next();
|
|
12059
14152
|
const tmp = `${this.manifestPath}.${process.pid}.tmp`;
|
|
12060
|
-
await
|
|
12061
|
-
await
|
|
14153
|
+
await fsp2.writeFile(tmp, JSON.stringify(manifest, null, 2));
|
|
14154
|
+
await fsp2.rename(tmp, this.manifestPath);
|
|
12062
14155
|
await this.pruneStore(manifest);
|
|
12063
14156
|
}
|
|
12064
14157
|
/**
|
|
@@ -12072,7 +14165,7 @@ var HmrHost = class {
|
|
|
12072
14165
|
const keepNewest = async (dir, keys, referenced, remove) => {
|
|
12073
14166
|
let names;
|
|
12074
14167
|
try {
|
|
12075
|
-
names = await
|
|
14168
|
+
names = await fsp2.readdir(dir);
|
|
12076
14169
|
} catch {
|
|
12077
14170
|
return;
|
|
12078
14171
|
}
|
|
@@ -12081,7 +14174,7 @@ var HmrHost = class {
|
|
|
12081
14174
|
const key = keys(name);
|
|
12082
14175
|
if (key === null) continue;
|
|
12083
14176
|
try {
|
|
12084
|
-
const mtime = (await
|
|
14177
|
+
const mtime = (await fsp2.stat(path21.join(dir, name))).mtimeMs;
|
|
12085
14178
|
byKey.set(key, Math.max(byKey.get(key) ?? 0, mtime));
|
|
12086
14179
|
} catch {
|
|
12087
14180
|
}
|
|
@@ -12093,29 +14186,37 @@ var HmrHost = class {
|
|
|
12093
14186
|
if (!kept.has(key)) await remove(key).catch(() => void 0);
|
|
12094
14187
|
}
|
|
12095
14188
|
};
|
|
12096
|
-
const platformDir =
|
|
14189
|
+
const platformDir = path21.join(this.storeDir, "platform");
|
|
12097
14190
|
const platformBundleRef = manifest.platform?.bundle.match(/([0-9a-f]+)\.mjs$/)?.[1] ?? null;
|
|
12098
14191
|
await keepNewest(
|
|
12099
14192
|
platformDir,
|
|
12100
14193
|
(name) => /^([0-9a-f]+)\.mjs$/.exec(name)?.[1] ?? null,
|
|
12101
14194
|
platformBundleRef,
|
|
12102
|
-
(sha) =>
|
|
14195
|
+
(sha) => fsp2.rm(path21.join(platformDir, `${sha}.mjs`), { force: true })
|
|
12103
14196
|
);
|
|
12104
|
-
const cliDir =
|
|
14197
|
+
const cliDir = path21.join(this.storeDir, "cli");
|
|
12105
14198
|
const cliRef = manifest.cli?.bundle.match(/([0-9a-f]+)\.mjs$/)?.[1] ?? null;
|
|
12106
14199
|
await keepNewest(
|
|
12107
14200
|
cliDir,
|
|
12108
14201
|
(name) => /^([0-9a-f]+)\.mjs$/.exec(name)?.[1] ?? null,
|
|
12109
14202
|
cliRef,
|
|
12110
|
-
(sha) =>
|
|
14203
|
+
(sha) => fsp2.rm(path21.join(cliDir, `${sha}.mjs`), { force: true })
|
|
12111
14204
|
);
|
|
12112
|
-
const webDir =
|
|
14205
|
+
const webDir = path21.join(this.storeDir, "web");
|
|
12113
14206
|
const webRef = manifest.web?.manifest.match(/([0-9a-f]+)\.webz$/)?.[1] ?? null;
|
|
12114
14207
|
await keepNewest(
|
|
12115
14208
|
webDir,
|
|
12116
14209
|
(name) => /^([0-9a-f]+)\.webz$/.exec(name)?.[1] ?? null,
|
|
12117
14210
|
webRef,
|
|
12118
|
-
(sha) =>
|
|
14211
|
+
(sha) => fsp2.rm(path21.join(webDir, `${sha}.webz`), { force: true })
|
|
14212
|
+
);
|
|
14213
|
+
const assetsRoot = path21.join(this.storeDir, "assets");
|
|
14214
|
+
const assetsRef = manifest.assets?.dir.match(/([0-9a-f]+)$/)?.[1] ?? null;
|
|
14215
|
+
await keepNewest(
|
|
14216
|
+
assetsRoot,
|
|
14217
|
+
(name) => /^[0-9a-f]+$/.test(name) ? name : null,
|
|
14218
|
+
assetsRef,
|
|
14219
|
+
(sha) => fsp2.rm(path21.join(assetsRoot, sha), { recursive: true, force: true })
|
|
12119
14220
|
);
|
|
12120
14221
|
}
|
|
12121
14222
|
/** Process-exit sweep only; never part of an upgrade. */
|
|
@@ -12129,13 +14230,22 @@ function errMsg(err) {
|
|
|
12129
14230
|
return err instanceof Error ? err.message : String(err);
|
|
12130
14231
|
}
|
|
12131
14232
|
function sha1(content) {
|
|
12132
|
-
return
|
|
14233
|
+
return crypto2.createHash("sha1").update(content).digest("hex");
|
|
12133
14234
|
}
|
|
12134
14235
|
function filesDigest(files) {
|
|
12135
|
-
const hash =
|
|
14236
|
+
const hash = crypto2.createHash("sha1");
|
|
12136
14237
|
for (const rel of Object.keys(files).sort()) hash.update(rel).update("\0").update(files[rel]);
|
|
12137
14238
|
return hash.digest("hex");
|
|
12138
14239
|
}
|
|
14240
|
+
async function sameFileContent(file, content) {
|
|
14241
|
+
try {
|
|
14242
|
+
const stat = await fsp2.stat(file);
|
|
14243
|
+
if (!stat.isFile() || stat.size !== content.length) return false;
|
|
14244
|
+
return (await fsp2.readFile(file)).equals(content);
|
|
14245
|
+
} catch {
|
|
14246
|
+
return false;
|
|
14247
|
+
}
|
|
14248
|
+
}
|
|
12139
14249
|
function isSafeRelPath(rel) {
|
|
12140
14250
|
if (rel === "" || rel.startsWith("/") || rel.includes("\\")) return false;
|
|
12141
14251
|
return rel.split("/").every((seg) => seg !== "" && seg !== "." && seg !== "..");
|
|
@@ -12164,10 +14274,10 @@ function filesMapFromGzip(gz) {
|
|
|
12164
14274
|
|
|
12165
14275
|
// src/hmr/routes.ts
|
|
12166
14276
|
import zlib2 from "zlib";
|
|
12167
|
-
import { Hono as
|
|
14277
|
+
import { Hono as Hono26 } from "hono";
|
|
12168
14278
|
var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["127.0.0.1", "::1", "localhost"]);
|
|
12169
14279
|
function hmrRoutes(deps2) {
|
|
12170
|
-
const routes = new
|
|
14280
|
+
const routes = new Hono26();
|
|
12171
14281
|
const hmr = deps2.hmr;
|
|
12172
14282
|
const cookieAuth = authMiddleware(deps2.authService);
|
|
12173
14283
|
routes.use("*", async (c, next) => {
|
|
@@ -12231,6 +14341,14 @@ function hmrRoutes(deps2) {
|
|
|
12231
14341
|
platform: payload.platform,
|
|
12232
14342
|
cli: payload.cli,
|
|
12233
14343
|
web: payload.web.files,
|
|
14344
|
+
// Optional: a push that needs no real files on disk (no native module, no helper
|
|
14345
|
+
// binary) simply omits it, and older pushers keep working unchanged.
|
|
14346
|
+
...payload.assets?.files ? {
|
|
14347
|
+
assets: {
|
|
14348
|
+
files: payload.assets.files,
|
|
14349
|
+
...payload.assets.exec ? { exec: payload.assets.exec } : {}
|
|
14350
|
+
}
|
|
14351
|
+
} : {},
|
|
12234
14352
|
...payload.source ? { source: payload.source } : {}
|
|
12235
14353
|
});
|
|
12236
14354
|
} catch (err) {
|
|
@@ -12282,10 +14400,10 @@ function platformHttpSeam(hmr) {
|
|
|
12282
14400
|
}
|
|
12283
14401
|
|
|
12284
14402
|
// src/http/routes/preview.ts
|
|
12285
|
-
import { Hono as
|
|
14403
|
+
import { Hono as Hono27 } from "hono";
|
|
12286
14404
|
var DIRECTORY_INDEX = "index.html";
|
|
12287
14405
|
function previewRoutes(deps2) {
|
|
12288
|
-
const app2 = new
|
|
14406
|
+
const app2 = new Hono27();
|
|
12289
14407
|
app2.get("/:token/*", async (c) => {
|
|
12290
14408
|
const token = c.req.param("token");
|
|
12291
14409
|
const payload = token ? deps2.previewTokens.verify(token) : null;
|
|
@@ -12378,7 +14496,22 @@ function buildAppDeps(config2, overrides = {}) {
|
|
|
12378
14496
|
});
|
|
12379
14497
|
const recorder = new UsageRecorder(usageRepo, overrides.now ?? (() => /* @__PURE__ */ new Date()));
|
|
12380
14498
|
const errors = new ErrorRecorder(errorsRepo, overrides.now ?? (() => /* @__PURE__ */ new Date()));
|
|
12381
|
-
const
|
|
14499
|
+
const notifyProjectUsers = (projectId, event) => {
|
|
14500
|
+
const ownerUserId = projectsRepo.findById(projectId)?.ownerUserId;
|
|
14501
|
+
if (ownerUserId === void 0) return;
|
|
14502
|
+
const audience = /* @__PURE__ */ new Set([ownerUserId, ...membersRepo.list(projectId).map((m) => m.userId)]);
|
|
14503
|
+
for (const userId of audience) {
|
|
14504
|
+
channels.peek(userChannelKey(userId))?.publish(event, "server_event");
|
|
14505
|
+
}
|
|
14506
|
+
};
|
|
14507
|
+
const titles = overrides.titles ?? new TitleGenerator({
|
|
14508
|
+
sessions: sessionsRepo,
|
|
14509
|
+
channels,
|
|
14510
|
+
recorder,
|
|
14511
|
+
errors,
|
|
14512
|
+
log,
|
|
14513
|
+
notifyProjectUsers
|
|
14514
|
+
});
|
|
12382
14515
|
const manager = new SessionManager({
|
|
12383
14516
|
sessions: sessionsRepo,
|
|
12384
14517
|
channels,
|
|
@@ -12390,21 +14523,8 @@ function buildAppDeps(config2, overrides = {}) {
|
|
|
12390
14523
|
log,
|
|
12391
14524
|
goals: goalsRepo,
|
|
12392
14525
|
// Run-state flips reach the whole login session, not just the tab watching that one
|
|
12393
|
-
// conversation
|
|
12394
|
-
|
|
12395
|
-
// they cannot open changed state.
|
|
12396
|
-
//
|
|
12397
|
-
// `peek`, deliberately not `get`: a user who has never opened an event stream has no
|
|
12398
|
-
// channel, and conjuring one to buffer badge updates nobody is listening to is pure waste
|
|
12399
|
-
// (their next connection fetches the list, which carries the same statuses anyway).
|
|
12400
|
-
notifyProjectUsers: (projectId, event) => {
|
|
12401
|
-
const ownerUserId = projectsRepo.findById(projectId)?.ownerUserId;
|
|
12402
|
-
if (ownerUserId === void 0) return;
|
|
12403
|
-
const audience = /* @__PURE__ */ new Set([ownerUserId, ...membersRepo.list(projectId).map((m) => m.userId)]);
|
|
12404
|
-
for (const userId of audience) {
|
|
12405
|
-
channels.peek(userChannelKey(userId))?.publish(event, "server_event");
|
|
12406
|
-
}
|
|
12407
|
-
},
|
|
14526
|
+
// conversation (see the shared publisher above for the audience).
|
|
14527
|
+
notifyProjectUsers,
|
|
12408
14528
|
...overrides.now ? { now: overrides.now } : {}
|
|
12409
14529
|
});
|
|
12410
14530
|
managerRef = manager;
|
|
@@ -12434,6 +14554,7 @@ function buildAppDeps(config2, overrides = {}) {
|
|
|
12434
14554
|
onPasswordChanged,
|
|
12435
14555
|
sessionTtlMs: config2.authSessionTtlMs,
|
|
12436
14556
|
sessionRenewMs: config2.authSessionRenewMs,
|
|
14557
|
+
...overrides.passwordHashCost !== void 0 ? { passwordHashCost: overrides.passwordHashCost } : {},
|
|
12437
14558
|
...overrides.now ? { now: overrides.now } : {}
|
|
12438
14559
|
});
|
|
12439
14560
|
const adminService = new AdminService({
|
|
@@ -12442,6 +14563,7 @@ function buildAppDeps(config2, overrides = {}) {
|
|
|
12442
14563
|
projects: projectsRepo,
|
|
12443
14564
|
projectService,
|
|
12444
14565
|
onPasswordChanged,
|
|
14566
|
+
...overrides.passwordHashCost !== void 0 ? { passwordHashCost: overrides.passwordHashCost } : {},
|
|
12445
14567
|
...overrides.now ? { now: overrides.now } : {}
|
|
12446
14568
|
});
|
|
12447
14569
|
const sessionService = new SessionService({
|
|
@@ -12467,6 +14589,12 @@ function buildAppDeps(config2, overrides = {}) {
|
|
|
12467
14589
|
},
|
|
12468
14590
|
...overrides.now ? { now: () => overrides.now().getTime() } : {}
|
|
12469
14591
|
});
|
|
14592
|
+
const hmr = new HmrHost(config2.root);
|
|
14593
|
+
hmr.resources.register(IDENTITY_RESOURCE_ID, async (request) => {
|
|
14594
|
+
const token = readSessionCookie(request.headers.get("cookie"));
|
|
14595
|
+
const authed = token === null ? null : authService.authenticateWithMeta(token);
|
|
14596
|
+
return authed === null ? null : { userId: authed.user.userId };
|
|
14597
|
+
});
|
|
12470
14598
|
return {
|
|
12471
14599
|
config: config2,
|
|
12472
14600
|
db,
|
|
@@ -12498,12 +14626,12 @@ function buildAppDeps(config2, overrides = {}) {
|
|
|
12498
14626
|
sessionSources,
|
|
12499
14627
|
errors,
|
|
12500
14628
|
desktop: config2.desktopToken !== null ? new DesktopService(config2.desktopToken) : null,
|
|
12501
|
-
hmr
|
|
14629
|
+
hmr,
|
|
12502
14630
|
log
|
|
12503
14631
|
};
|
|
12504
14632
|
}
|
|
12505
14633
|
function createApp(deps2) {
|
|
12506
|
-
const app2 = new
|
|
14634
|
+
const app2 = new Hono28();
|
|
12507
14635
|
app2.onError((err, c) => {
|
|
12508
14636
|
const projectId = attributedProjectId(c, deps2);
|
|
12509
14637
|
deps2.errors.record({
|
|
@@ -12567,6 +14695,9 @@ function createApp(deps2) {
|
|
|
12567
14695
|
const auth = authMiddleware(deps2.authService);
|
|
12568
14696
|
app2.use("/api/*", auth);
|
|
12569
14697
|
app2.route("/api/me", meRoutes(deps2));
|
|
14698
|
+
if (deps2.desktop) {
|
|
14699
|
+
app2.route("/api/desktop/update", desktopUpdateRoutes(deps2));
|
|
14700
|
+
}
|
|
12570
14701
|
app2.route("/api/version", versionRoutes(deps2));
|
|
12571
14702
|
app2.route("/api/admin/users", adminUsersRoutes(deps2));
|
|
12572
14703
|
app2.route("/api/admin/settings", adminSettingsRoutes(deps2));
|
|
@@ -12576,6 +14707,7 @@ function createApp(deps2) {
|
|
|
12576
14707
|
app2.route("/api/projects/:projectId/members", membersRoutes(deps2));
|
|
12577
14708
|
app2.route("/api/projects/:projectId/models", modelsRoutes(deps2));
|
|
12578
14709
|
app2.route("/api/projects/:projectId/chat-defaults", chatDefaultsRoutes(deps2));
|
|
14710
|
+
app2.route("/api/projects/:projectId/command-policy", commandPolicyRoutes(deps2));
|
|
12579
14711
|
app2.route("/api/projects/:projectId/agents", agentsRoutes(deps2));
|
|
12580
14712
|
app2.route("/api/projects/:projectId/dirs", dirsRoutes(deps2));
|
|
12581
14713
|
app2.route("/api/projects/:projectId/agents/:agentId/config", agentConfigRoutes(deps2));
|
|
@@ -12634,53 +14766,63 @@ function registerStaticRoutes(app2, resolveSource) {
|
|
|
12634
14766
|
if (content2 === void 0) {
|
|
12635
14767
|
return c.json(errorBody("not_found", "Resource does not exist."), 404);
|
|
12636
14768
|
}
|
|
12637
|
-
const type3 = CONTENT_TYPES2[
|
|
14769
|
+
const type3 = CONTENT_TYPES2[path22.extname(servedPath).toLowerCase()] ?? "application/octet-stream";
|
|
12638
14770
|
return new Response(new Uint8Array(content2), {
|
|
12639
14771
|
status: 200,
|
|
12640
14772
|
headers: { "Content-Type": type3 }
|
|
12641
14773
|
});
|
|
12642
14774
|
}
|
|
12643
14775
|
const webDist = source.dir;
|
|
12644
|
-
if (!
|
|
14776
|
+
if (!fs23.existsSync(webDist)) {
|
|
12645
14777
|
return c.json(errorBody("not_found", "Resource does not exist."), 404);
|
|
12646
14778
|
}
|
|
12647
|
-
const resolved =
|
|
12648
|
-
const base =
|
|
12649
|
-
const target = resolved === base || resolved.startsWith(base +
|
|
14779
|
+
const resolved = path22.resolve(webDist, rel);
|
|
14780
|
+
const base = path22.resolve(webDist);
|
|
14781
|
+
const target = resolved === base || resolved.startsWith(base + path22.sep) ? resolved : path22.join(base, "index.html");
|
|
12650
14782
|
let file = target;
|
|
12651
14783
|
try {
|
|
12652
|
-
const stat = await
|
|
12653
|
-
if (stat.isDirectory()) file =
|
|
12654
|
-
await
|
|
14784
|
+
const stat = await fsp3.stat(file);
|
|
14785
|
+
if (stat.isDirectory()) file = path22.join(file, "index.html");
|
|
14786
|
+
await fsp3.access(file);
|
|
12655
14787
|
} catch {
|
|
12656
|
-
file =
|
|
14788
|
+
file = path22.join(base, "index.html");
|
|
12657
14789
|
}
|
|
12658
14790
|
let content;
|
|
12659
14791
|
try {
|
|
12660
|
-
content = await
|
|
14792
|
+
content = await fsp3.readFile(file);
|
|
12661
14793
|
} catch {
|
|
12662
14794
|
return c.json(errorBody("not_found", "Resource does not exist."), 404);
|
|
12663
14795
|
}
|
|
12664
|
-
const type2 = CONTENT_TYPES2[
|
|
14796
|
+
const type2 = CONTENT_TYPES2[path22.extname(file).toLowerCase()] ?? "application/octet-stream";
|
|
12665
14797
|
return new Response(new Uint8Array(content), {
|
|
12666
14798
|
status: 200,
|
|
12667
14799
|
headers: { "Content-Type": type2 }
|
|
12668
14800
|
});
|
|
12669
14801
|
});
|
|
12670
14802
|
}
|
|
14803
|
+
function readSessionCookie(header) {
|
|
14804
|
+
if (header === null) return null;
|
|
14805
|
+
for (const part of header.split(";")) {
|
|
14806
|
+
const eq = part.indexOf("=");
|
|
14807
|
+
if (eq === -1) continue;
|
|
14808
|
+
if (part.slice(0, eq).trim() !== SESSION_COOKIE) continue;
|
|
14809
|
+
return decodeURIComponent(part.slice(eq + 1).trim());
|
|
14810
|
+
}
|
|
14811
|
+
return null;
|
|
14812
|
+
}
|
|
12671
14813
|
|
|
12672
14814
|
// src/config.ts
|
|
12673
|
-
import { randomBytes as
|
|
12674
|
-
import
|
|
12675
|
-
import
|
|
14815
|
+
import { randomBytes as randomBytes6 } from "crypto";
|
|
14816
|
+
import fs24 from "fs";
|
|
14817
|
+
import path23 from "path";
|
|
12676
14818
|
import { fileURLToPath } from "url";
|
|
12677
14819
|
import { DEFAULT_SERVER_PORT, resolveRoot } from "@prismshadow/penguin-core";
|
|
12678
14820
|
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
12679
14821
|
function defaultWebDist() {
|
|
12680
|
-
const here =
|
|
12681
|
-
const bundled =
|
|
12682
|
-
if (
|
|
12683
|
-
return
|
|
14822
|
+
const here = path23.dirname(fileURLToPath(import.meta.url));
|
|
14823
|
+
const bundled = path23.resolve(here, "..", "web-dist");
|
|
14824
|
+
if (fs24.existsSync(bundled)) return bundled;
|
|
14825
|
+
return path23.resolve(here, "..", "..", "web", "dist");
|
|
12684
14826
|
}
|
|
12685
14827
|
function normalizePreviewOrigin(raw) {
|
|
12686
14828
|
if (!raw || raw.trim() === "") return null;
|
|
@@ -12710,14 +14852,14 @@ function resolveServerConfig(env = process.env) {
|
|
|
12710
14852
|
root,
|
|
12711
14853
|
host,
|
|
12712
14854
|
port,
|
|
12713
|
-
dbPath: env.PENGUIN_WEB_DB ??
|
|
14855
|
+
dbPath: env.PENGUIN_WEB_DB ?? path23.join(root, "web.db"),
|
|
12714
14856
|
webDist: env.PENGUIN_WEB_DIST ?? defaultWebDist(),
|
|
12715
14857
|
previewOrigin: normalizePreviewOrigin(env.PENGUIN_PREVIEW_ORIGIN),
|
|
12716
14858
|
// An empty/whitespace value is treated as unset (→ random seed password). Desktop
|
|
12717
14859
|
// mode without a pinned value seeds a FULLY random password rather than the
|
|
12718
14860
|
// printable penguin-<4 digits>: desktop sign-in goes through the shell's token, so
|
|
12719
14861
|
// the seed never needs to be read — and index.ts deliberately does not print it.
|
|
12720
|
-
seedAdminPassword: env.PENGUIN_SEED_ADMIN_PASSWORD?.trim() || (desktopToken !== null ?
|
|
14862
|
+
seedAdminPassword: env.PENGUIN_SEED_ADMIN_PASSWORD?.trim() || (desktopToken !== null ? randomBytes6(24).toString("base64url") : null),
|
|
12721
14863
|
authSessionTtlMs: 7 * DAY_MS,
|
|
12722
14864
|
authSessionRenewMs: 6 * DAY_MS,
|
|
12723
14865
|
desktopToken,
|
|
@@ -12726,6 +14868,108 @@ function resolveServerConfig(env = process.env) {
|
|
|
12726
14868
|
};
|
|
12727
14869
|
}
|
|
12728
14870
|
|
|
14871
|
+
// src/terminal/ws.ts
|
|
14872
|
+
import { WebSocketServer } from "ws";
|
|
14873
|
+
var STREAM_PATH = /^\/api\/terminals\/([^/]+)\/stream$/;
|
|
14874
|
+
var BACKPRESSURE_HIGH_WATER2 = 1024 * 1024;
|
|
14875
|
+
var BACKPRESSURE_LOW_WATER2 = 64 * 1024;
|
|
14876
|
+
function attachTerminalWebSocket(server2, deps2) {
|
|
14877
|
+
const wss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
|
|
14878
|
+
server2.on("upgrade", (req, socket, head) => {
|
|
14879
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
14880
|
+
const match = STREAM_PATH.exec(url.pathname);
|
|
14881
|
+
if (!match) return;
|
|
14882
|
+
if (!isAllowedOrigin(req)) return refuse(socket, 403, "Forbidden");
|
|
14883
|
+
const token = readCookie(req.headers.cookie, SESSION_COOKIE);
|
|
14884
|
+
const authed = token ? deps2.authService.authenticateWithMeta(token) : null;
|
|
14885
|
+
if (!authed) return refuse(socket, 401, "Unauthorized");
|
|
14886
|
+
void deps2.hmr.ensure().then((platform) => {
|
|
14887
|
+
const manager = platform.api.terminals?.();
|
|
14888
|
+
const session = manager?.get(match[1]);
|
|
14889
|
+
if (!session || session.ownerUserId !== authed.user.userId) {
|
|
14890
|
+
return refuse(socket, 404, "Not Found");
|
|
14891
|
+
}
|
|
14892
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
14893
|
+
platform.api.attachStream?.(ws, session, url, deps2.log);
|
|
14894
|
+
});
|
|
14895
|
+
}).catch((err) => {
|
|
14896
|
+
deps2.log(`[terminal] stream upgrade failed: ${err instanceof Error ? err.message : err}`);
|
|
14897
|
+
refuse(socket, 500, "Internal Server Error");
|
|
14898
|
+
});
|
|
14899
|
+
});
|
|
14900
|
+
}
|
|
14901
|
+
function isAllowedOrigin(req) {
|
|
14902
|
+
const origin = req.headers.origin;
|
|
14903
|
+
if (!origin) return true;
|
|
14904
|
+
let parsed;
|
|
14905
|
+
try {
|
|
14906
|
+
parsed = new URL(origin);
|
|
14907
|
+
} catch {
|
|
14908
|
+
return false;
|
|
14909
|
+
}
|
|
14910
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
|
|
14911
|
+
return parsed.host === (req.headers.host ?? "");
|
|
14912
|
+
}
|
|
14913
|
+
function readCookie(header, name) {
|
|
14914
|
+
if (!header) return null;
|
|
14915
|
+
for (const part of header.split(";")) {
|
|
14916
|
+
const eq = part.indexOf("=");
|
|
14917
|
+
if (eq === -1) continue;
|
|
14918
|
+
if (part.slice(0, eq).trim() !== name) continue;
|
|
14919
|
+
try {
|
|
14920
|
+
return decodeURIComponent(part.slice(eq + 1).trim());
|
|
14921
|
+
} catch {
|
|
14922
|
+
return null;
|
|
14923
|
+
}
|
|
14924
|
+
}
|
|
14925
|
+
return null;
|
|
14926
|
+
}
|
|
14927
|
+
function refuse(socket, status, text) {
|
|
14928
|
+
socket.write(`HTTP/1.1 ${status} ${text}\r
|
|
14929
|
+
Connection: close\r
|
|
14930
|
+
\r
|
|
14931
|
+
`);
|
|
14932
|
+
socket.destroy();
|
|
14933
|
+
}
|
|
14934
|
+
|
|
14935
|
+
// src/services/desktop-update-port.ts
|
|
14936
|
+
var UPDATE_STATES = /* @__PURE__ */ new Set([
|
|
14937
|
+
"idle",
|
|
14938
|
+
"checking",
|
|
14939
|
+
"up-to-date",
|
|
14940
|
+
"downloading",
|
|
14941
|
+
"downloaded",
|
|
14942
|
+
"error",
|
|
14943
|
+
"unsupported"
|
|
14944
|
+
]);
|
|
14945
|
+
function parseUpdaterStatusMessage(data) {
|
|
14946
|
+
if (typeof data !== "object" || data === null) return null;
|
|
14947
|
+
const msg = data;
|
|
14948
|
+
if (msg.type !== "desktop-updater-status") return null;
|
|
14949
|
+
const status = msg.status;
|
|
14950
|
+
if (typeof status !== "object" || status === null) return null;
|
|
14951
|
+
if (typeof status.appVersion !== "string") return null;
|
|
14952
|
+
if (typeof status.state !== "string" || !UPDATE_STATES.has(status.state)) return null;
|
|
14953
|
+
if (status.seq !== void 0 && typeof status.seq !== "number") return null;
|
|
14954
|
+
return status;
|
|
14955
|
+
}
|
|
14956
|
+
function shellPortOf(proc) {
|
|
14957
|
+
const port = proc.parentPort;
|
|
14958
|
+
return port && typeof port.on === "function" && typeof port.postMessage === "function" ? port : null;
|
|
14959
|
+
}
|
|
14960
|
+
function wireShellUpdatePort(desktop, port) {
|
|
14961
|
+
port.on("message", (e) => {
|
|
14962
|
+
const status = parseUpdaterStatusMessage(e.data);
|
|
14963
|
+
if (status !== null) desktop.setUpdateStatus(status);
|
|
14964
|
+
});
|
|
14965
|
+
desktop.onUpdateCommand((action) => {
|
|
14966
|
+
port.postMessage({
|
|
14967
|
+
type: "desktop-updater-command",
|
|
14968
|
+
action
|
|
14969
|
+
});
|
|
14970
|
+
});
|
|
14971
|
+
}
|
|
14972
|
+
|
|
12729
14973
|
// src/index.ts
|
|
12730
14974
|
loadDotenv({ quiet: true });
|
|
12731
14975
|
installGlobalProxyDispatcher();
|
|
@@ -12764,12 +15008,17 @@ deps.goalsRepo.abortOrphanedActive();
|
|
|
12764
15008
|
var appHost = loopbackHostRoles(config.host)?.app ?? config.host;
|
|
12765
15009
|
var ipv6Loopback = null;
|
|
12766
15010
|
function writePortFile(file, port) {
|
|
12767
|
-
|
|
15011
|
+
fs25.mkdirSync(path24.dirname(file), { recursive: true });
|
|
12768
15012
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
12769
|
-
|
|
15013
|
+
fs25.writeFileSync(tmp, `${port}
|
|
12770
15014
|
`);
|
|
12771
|
-
|
|
15015
|
+
fs25.renameSync(tmp, file);
|
|
12772
15016
|
}
|
|
15017
|
+
var terminalWebSocketDeps = {
|
|
15018
|
+
hmr: deps.hmr,
|
|
15019
|
+
authService: deps.authService,
|
|
15020
|
+
log: (line) => console.log(line)
|
|
15021
|
+
};
|
|
12773
15022
|
var server = serve({ fetch: app.fetch, hostname: config.host, port: config.port }, (info) => {
|
|
12774
15023
|
console.log(`penguin-server started: http://${appHost}:${info.port}`);
|
|
12775
15024
|
console.log(`Data root: ${config.root}`);
|
|
@@ -12789,13 +15038,15 @@ var server = serve({ fetch: app.fetch, hostname: config.host, port: config.port
|
|
|
12789
15038
|
`[server] IPv6 loopback listener unavailable (${err.code ?? err.message}); previews via localhost may not resolve.`
|
|
12790
15039
|
);
|
|
12791
15040
|
});
|
|
15041
|
+
attachTerminalWebSocket(ipv6Loopback, terminalWebSocketDeps);
|
|
12792
15042
|
}
|
|
12793
15043
|
});
|
|
15044
|
+
attachTerminalWebSocket(server, terminalWebSocketDeps);
|
|
12794
15045
|
function cleanupInstanceFiles() {
|
|
12795
15046
|
releaseServerLock(config.root);
|
|
12796
15047
|
if (config.portFile !== null) {
|
|
12797
15048
|
try {
|
|
12798
|
-
|
|
15049
|
+
fs25.rmSync(config.portFile, { force: true });
|
|
12799
15050
|
} catch {
|
|
12800
15051
|
}
|
|
12801
15052
|
}
|
|
@@ -12823,6 +15074,10 @@ async function shutdown(signal, exitCode = 0) {
|
|
|
12823
15074
|
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
12824
15075
|
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
12825
15076
|
deps.desktop?.onShutdownRequest(() => void shutdown("desktop-shutdown"));
|
|
15077
|
+
if (deps.desktop !== null) {
|
|
15078
|
+
const shellPort = shellPortOf(process);
|
|
15079
|
+
if (shellPort !== null) wireShellUpdatePort(deps.desktop, shellPort);
|
|
15080
|
+
}
|
|
12826
15081
|
process.on("uncaughtException", (err) => {
|
|
12827
15082
|
console.error(`[server] Uncaught exception: ${err.stack ?? err.message}`);
|
|
12828
15083
|
deps.errors.record({ source: "process", err, code: "uncaught_exception" });
|