@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/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-3QINBW3W.js";
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 fs24 from "fs";
30
- import path19 from "path";
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 fs22 from "fs";
36
- import fsp2 from "fs/promises";
37
- import path17 from "path";
38
- import { Hono as Hono26 } from "hono";
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
- * Raw success-rate counts per Model (paired reference) (completed / non-aborted requests):
1088
- * powers the cost center's "Model Success Rate" chart. The denominator excludes aborted
1089
- * (user-initiated interruption); failure breakdowns (failed / timeout / malformed) are
1090
- * also returned for hover display. Unknown statuses aren't broken out but still count
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
- statusByModel(projectId, f = {}) {
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 provider, model_id,
1098
- ${count("completed")},
1099
- ${count("aborted")},
1100
- ${count("failed")},
1101
- ${count("timeout")},
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
- provider: r.provider,
1108
- modelId: r.model_id,
1137
+ key: r.key,
1138
+ agentId: r.agent_id,
1139
+ requests: r.requests,
1109
1140
  completed: r.completed,
1110
- total: r.total,
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, path20) {
1819
- return baseUrl.trim().replace(/\/+$/, "") + path20;
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/vault.ts
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 Hono12();
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 Hono13 } from "hono";
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 Hono13();
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 Hono14 } from "hono";
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 cached = this.seen.get(key);
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 && cached !== void 0 && cached.mtimeMs !== null && cached.mtimeMs === dirMtime) {
3990
- next.mtimeMs = cached.mtimeMs;
3991
- names = [...cached.files.keys()];
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, cached?.files.get(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, cached) {
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 (cached && cached.mtimeMs === stat.mtimeMs && cached.sizeBytes === stat.size) return cached;
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 Hono14();
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: invalid
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 = invalid.find((i) => i.name === name);
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 Hono15 } from "hono";
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 Hono15();
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 Hono16 } from "hono";
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 Hono16();
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 Hono16();
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 Hono17 } from "hono";
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 Hono17();
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 Hono18 } from "hono";
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 Hono18();
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 Hono19 } from "hono";
4978
+ import { Hono as Hono20 } from "hono";
4728
4979
  function dirsRoutes(deps2) {
4729
- const app2 = new Hono19();
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 Hono20 } from "hono";
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 Hono20();
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 Hono21 } from "hono";
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 Hono21();
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 Hono22 } from "hono";
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 Hono22();
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 Hono23 } from "hono";
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 Hono23();
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): it collects model text for automatic title generation.
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
- for (const child of children.values()) {
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** — it's generated at the end of this run by the model
6888
- * from the subagent's own conversation (see drive's finally), falling back to the
6889
- * first line of the run_subagent prompt if generation fails. Idempotent (children
6890
- * dedup + insertOrIgnore); a subagent has its own Trace, so it's visible in both the
6891
- * list and the trace view. On successful registration, the entry is put into
6892
- * `children` and returned; a duplicate session_meta returns null.
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
- modelId: p.model_id,
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 invalid = [];
7457
+ const invalid2 = [];
7074
7458
  for (const file of files) {
7075
7459
  if (!file.parsed.ok) {
7076
- invalid.push({ name: file.name, error: file.parsed.error });
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
- invalid.push({ name: file.name, error: refError });
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 cached = this.agentDirs.get(projectId);
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 (cached && cached.mtimeMs === mtimeMs) return cached.ids;
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 cached?.ids.filter((id) => !ids.includes(id)) ?? []) {
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
- userText2(scheduledMessage(def.name, firedAt, def.prompt))
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
- /** Generate a title in the background (fire-and-forget) when conditions are met: the row exists, title is still NULL, and no generation is already in flight. */
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
- const row = this.deps.sessions.findById(ctx.sessionId);
7341
- if (!row || row.title !== null) return;
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
- const finalTitle = title ?? fallbackTitle(req.fallbackText);
7378
- if (finalTitle === null) return;
7801
+ if (title === null) return;
7379
7802
  const latest = this.deps.sessions.findById(ctx.sessionId);
7380
- if (!latest || latest.title !== null) return;
7381
- this.deps.sessions.updateTitle(ctx.sessionId, finalTitle);
7382
- this.deps.channels.get(req.notifyOn ?? ctx.sessionId).publish(
7383
- { type: "session_title", sessionId: ctx.sessionId, title: finalTitle },
7384
- "server_event"
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
- return sanitizeTitle(firstLine) ?? firstLine.trim().slice(0, 30);
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 cached = this.cache.get(projectId);
7749
- if (cached && cached.mtimeMs === mtimeMs) return cached.table;
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 disabled, a tiny output cap, 20s timeout — just to see
7923
- * whether the endpoint answers. The model id sent to AgentHub is `modelId`
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 may ignore the disabled thinking level and burn the
7927
- * whole tiny output cap on thinking (finish_reason=length with no text — AgentHub
7928
- * raises EmptyResponseError, collapsed to a malformed outcome): the endpoint
7929
- * demonstrably streamed model output, which is everything a connectivity test
7930
- * proves, so that case counts as ok too (see probeVerdict).
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
- thinkingLevel: "none",
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
- thinkingLevel: "none",
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 = (path20, value) => {
8465
- if (value !== void 0) doc.setIn(path20, value);
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 escaped = fileName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
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 randomBytes4 } from "crypto";
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-${randomBytes4(4).toString("hex")}`;
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-${randomBytes4(6).toString("hex")}`);
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(path20) {
10354
+ async function dirExists(path25) {
9537
10355
  try {
9538
- const stat = await fs18.stat(path20);
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 cached = this.seen.get(key);
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 && cached && cached.root === rootMtime) {
9970
- const dates = [...cached.dates.keys()].sort();
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 !== cached.dates.get(newest);
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 !== cached.dates.get(dates[i]));
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 && cached?.dates.get(date) === m) continue;
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 = matchAttachedImageLine(trimmed);
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 = matchAttachedFileLine(trimmed);
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(path20) {
10535
- this.deps.observeShardRead?.(path20);
10536
- return readTraceTolerant2(path20);
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 cached = ctx.raw.get(childSid);
10594
- if (cached !== void 0) return cached;
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 cached = deserializePrefix(
11504
+ const cached2 = deserializePrefix(
10687
11505
  this.deps.index.repo.getPageStats(projectId, agentId, sessionId, file.index)
10688
11506
  );
10689
- if (cached !== null) {
10690
- states.push(cached);
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 invalid = (message) => new HttpError(400, "invalid_fork_position", message);
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 invalid("The selected reply no longer exists in this Session.");
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 invalid("The fork position must identify a completed assistant reply.");
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 invalid("A compaction summary cannot be used as a fork point.");
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 invalid("The selected record is not the final assistant reply of its Task.");
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 invalid("The selected assistant reply belongs to an incomplete request.");
11657
+ else throw invalid2("The selected assistant reply belongs to an incomplete request.");
10840
11658
  }
10841
11659
  }
10842
- if (!completedRequest) throw invalid("The selected assistant reply has not completed yet.");
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 invalid("The selected reply is not a complete resumable Task boundary.");
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 invalid("The source Trace has no session metadata.");
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 invalid(`Trace shard ${sourceFile.index} does not start with session metadata.`);
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 invalid = (message) => new HttpError(400, "invalid_trace", message);
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 invalid("The file is not valid Trace JSONL.");
12201
+ throw invalid2("The file is not valid Trace JSONL.");
11384
12202
  }
11385
- if (records.length === 0) throw invalid("The file contains no Trace records.");
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 invalid("The first record of a Trace file must be session_meta.");
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 invalid("session_meta carries a missing or invalid session_id.");
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 = 20;
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, win(q.from, q.to));
11561
- const trendFrom = localDateMinusDays(this.now(), 29);
11562
- const trendRows = this.usage.groupsByModel(projectId, "date", win(trendFrom));
11563
- const agentRows = this.usage.groupsByModel(projectId, "agent", {
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 statusRows = this.usage.statusByModel(projectId, {
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
- ...q.from !== void 0 ? { from: q.from } : {},
11572
- ...q.to !== void 0 ? { to: q.to } : {}
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, ...trendRows]) {
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
- trend: this.foldTrend(trendRows, rates),
11605
- byAgent: [...byAgentMap.entries()].map(([agentId, v]) => ({ agentId, requests: v.requests, total: v.total })).sort((a, b) => b.requests - a.requests),
11606
- success: statusRows.sort((a, b) => b.total - a.total),
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
- foldTrend(rows, rates) {
11694
- const byDate = /* @__PURE__ */ new Map();
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 = byDate.get(r.key) ?? {
11697
- total: 0,
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 [...byDate.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([date, v]) => ({
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 crypto from "crypto";
11724
- import fs21 from "fs";
11725
- import fsp from "fs/promises";
11726
- import path16 from "path";
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(_ctx, context) {
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
- park: () => ({ motd: context.motd }),
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 = path16.join(root, "hmr");
11791
- this.storeDir = path16.join(this.hmrDir, "store");
11792
- this.manifestPath = path16.join(this.hmrDir, "harness.json");
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 fsp.readFile(this.manifestPath, "utf8"));
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 = path16.join(this.hmrDir, manifest.cli.bundle);
11896
- if (!fs21.existsSync(cliPath)) {
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
- const bundle = await this.importBundleFile(path16.join(this.hmrDir, manifest.platform.bundle));
11900
- const gz = await fsp.readFile(path16.join(this.hmrDir, manifest.web.manifest));
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 result = await upgrade({
11940
- current: current2,
11941
- impl: bundle.impl,
11942
- iface: bundle.iface,
11943
- resources: this.resources
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 = path16.resolve(file);
11976
- if (!fs21.existsSync(resolved)) throw new Error(`bundle file '${file}' does not exist`);
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 = path16.join(this.hmrDir, "uploads");
11999
- await fsp.mkdir(dir, { recursive: true });
12000
- const file = path16.join(dir, `platform-${sha1(content).slice(0, 16)}.mjs`);
12001
- await fsp.writeFile(file, content, "utf8");
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
- async persistVersion(platformContent, cliContent, webGz, webSha) {
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 = path16.join(this.storeDir, "platform");
12035
- await fsp.mkdir(platformDir, { recursive: true });
12036
- await fsp.writeFile(path16.join(platformDir, `${platformSha}.mjs`), platformContent, "utf8");
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 = path16.join(this.storeDir, "cli");
12039
- await fsp.mkdir(cliDir, { recursive: true });
12040
- await fsp.writeFile(path16.join(cliDir, `${cliSha}.mjs`), cliContent, "utf8");
12041
- const webDir = path16.join(this.storeDir, "web");
12042
- await fsp.mkdir(webDir, { recursive: true });
12043
- await fsp.writeFile(path16.join(webDir, `${webSha}.webz`), webGz);
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 fsp.mkdir(this.hmrDir, { recursive: true });
14150
+ await fsp2.mkdir(this.hmrDir, { recursive: true });
12058
14151
  const manifest = next();
12059
14152
  const tmp = `${this.manifestPath}.${process.pid}.tmp`;
12060
- await fsp.writeFile(tmp, JSON.stringify(manifest, null, 2));
12061
- await fsp.rename(tmp, this.manifestPath);
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 fsp.readdir(dir);
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 fsp.stat(path16.join(dir, name))).mtimeMs;
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 = path16.join(this.storeDir, "platform");
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) => fsp.rm(path16.join(platformDir, `${sha}.mjs`), { force: true })
14195
+ (sha) => fsp2.rm(path21.join(platformDir, `${sha}.mjs`), { force: true })
12103
14196
  );
12104
- const cliDir = path16.join(this.storeDir, "cli");
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) => fsp.rm(path16.join(cliDir, `${sha}.mjs`), { force: true })
14203
+ (sha) => fsp2.rm(path21.join(cliDir, `${sha}.mjs`), { force: true })
12111
14204
  );
12112
- const webDir = path16.join(this.storeDir, "web");
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) => fsp.rm(path16.join(webDir, `${sha}.webz`), { force: true })
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 crypto.createHash("sha1").update(content).digest("hex");
14233
+ return crypto2.createHash("sha1").update(content).digest("hex");
12133
14234
  }
12134
14235
  function filesDigest(files) {
12135
- const hash = crypto.createHash("sha1");
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 Hono24 } from "hono";
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 Hono24();
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 Hono25 } from "hono";
14403
+ import { Hono as Hono27 } from "hono";
12286
14404
  var DIRECTORY_INDEX = "index.html";
12287
14405
  function previewRoutes(deps2) {
12288
- const app2 = new Hono25();
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 titles = overrides.titles ?? new TitleGenerator({ sessions: sessionsRepo, channels, recorder, errors, log });
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. Audience = the Project's owner plus its members, i.e. exactly who
12394
- // ProjectsRepo.listAccessible would grant the Project to — nobody learns that a Session
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: new HmrHost(config2.root),
14629
+ hmr,
12502
14630
  log
12503
14631
  };
12504
14632
  }
12505
14633
  function createApp(deps2) {
12506
- const app2 = new Hono26();
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[path17.extname(servedPath).toLowerCase()] ?? "application/octet-stream";
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 (!fs22.existsSync(webDist)) {
14776
+ if (!fs23.existsSync(webDist)) {
12645
14777
  return c.json(errorBody("not_found", "Resource does not exist."), 404);
12646
14778
  }
12647
- const resolved = path17.resolve(webDist, rel);
12648
- const base = path17.resolve(webDist);
12649
- const target = resolved === base || resolved.startsWith(base + path17.sep) ? resolved : path17.join(base, "index.html");
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 fsp2.stat(file);
12653
- if (stat.isDirectory()) file = path17.join(file, "index.html");
12654
- await fsp2.access(file);
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 = path17.join(base, "index.html");
14788
+ file = path22.join(base, "index.html");
12657
14789
  }
12658
14790
  let content;
12659
14791
  try {
12660
- content = await fsp2.readFile(file);
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[path17.extname(file).toLowerCase()] ?? "application/octet-stream";
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 randomBytes5 } from "crypto";
12674
- import fs23 from "fs";
12675
- import path18 from "path";
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 = path18.dirname(fileURLToPath(import.meta.url));
12681
- const bundled = path18.resolve(here, "..", "web-dist");
12682
- if (fs23.existsSync(bundled)) return bundled;
12683
- return path18.resolve(here, "..", "..", "web", "dist");
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 ?? path18.join(root, "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 ? randomBytes5(24).toString("base64url") : 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
- fs24.mkdirSync(path19.dirname(file), { recursive: true });
15011
+ fs25.mkdirSync(path24.dirname(file), { recursive: true });
12768
15012
  const tmp = `${file}.${process.pid}.tmp`;
12769
- fs24.writeFileSync(tmp, `${port}
15013
+ fs25.writeFileSync(tmp, `${port}
12770
15014
  `);
12771
- fs24.renameSync(tmp, file);
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
- fs24.rmSync(config.portFile, { force: true });
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" });