@epoch-agent/server 0.9.0 → 0.10.0

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
@@ -1,12 +1,13 @@
1
1
  import { createServer } from 'http';
2
- import { t, ApprovalRelay, QuestionRelay, serializeStream, withRoleScope, withModelScope, withToolScope, nextRunAt, observeBackgroundTasks, skillIndexDescription, SANDBOX_EXCLUDES, SANDBOX_COVERS, isSystemOpenable, backgroundTaskOutput, listBackgroundTasks, providerLabel, MAX_REASON_CHARS, MAX_OBJECTIVE_CHARS, MAX_GOAL_MAX_ROUNDS, DEFAULT_GOAL_MAX_ROUNDS, SCHEDULE_DEFAULT_ALLOWLIST, SCHEDULE_DEFAULTS, coalesceRecording, readRecording } from '@epoch-agent/runtime';
2
+ import { createLogger, t, ApprovalRelay, QuestionRelay, serializeStream, withRoleScope, withModelScope, withToolScope, nextRunAt, observeBackgroundTasks, skillIndexDescription, SANDBOX_EXCLUDES, SANDBOX_COVERS, isSystemOpenable, backgroundTaskOutput, listBackgroundTasks, providerLabel, MAX_REASON_CHARS, MAX_OBJECTIVE_CHARS, MAX_GOAL_MAX_ROUNDS, DEFAULT_GOAL_MAX_ROUNDS, SCHEDULE_DEFAULT_ALLOWLIST, SCHEDULE_DEFAULTS, coalesceRecording, readRecording } from '@epoch-agent/runtime';
3
3
  import { timingSafeEqual, randomBytes } from 'crypto';
4
- import { PLAN_OUTCOMES, WIRE_AUTH_COOKIE, WIRE_AUTH_COOKIE_ATTRS, WIRE_AUTH_TOKEN_PARAM, extractAllMentions, MAX_ATTACH_FILES, utf8ByteLength, MAX_SESSION_REFS, attachSessionSurface, isWireRewindScope, WIRE_REWIND_SCOPES, MAX_ATTACH_TOTAL_BYTES, MAX_ATTACH_BYTES, WIRE_LANG_PARAM, isLang, isQuestionAnswerMap, isProviderType, apiKeyEnvVar, WIRE_FILE_STAT_MAX, WIRE_SETTING_WRITE_LAYERS, isWirePlanAction, WIRE_PLAN_ACTIONS, isPermissionLevel, PERMISSION_LEVELS, collectPendingApprovals, unfixedRules, isWireSettingWriteLayer, parseModelRef, WIRE_GOAL_ACTIONS, OPERATION_TYPES, isOperationType, WIRE_PLUGIN_UPLOAD_FORM_FIELDS } from '@epoch-agent/protocol';
4
+ import { PLAN_OUTCOMES, WIRE_AUTH_COOKIE, WIRE_AUTH_COOKIE_ATTRS, WIRE_AUTH_TOKEN_PARAM, extractAllMentions, MAX_ATTACH_FILES, utf8ByteLength, MAX_SESSION_REFS, attachSessionSurface, isWireRewindScope, WIRE_REWIND_SCOPES, mcpConnectorState, MAX_ATTACH_TOTAL_BYTES, MAX_ATTACH_BYTES, truncateToBudget, WIRE_LANG_PARAM, isLang, isQuestionAnswerMap, isProviderType, apiKeyEnvVar, WIRE_FILE_STAT_MAX, WIRE_SETTING_WRITE_LAYERS, isWirePlanAction, WIRE_PLAN_ACTIONS, isPermissionLevel, PERMISSION_LEVELS, conformsToWire, WIRE_TRUST_REQUEST_FIELDS, WIRE_FORGET_WORKSPACE_FIELDS, collectPendingApprovals, unfixedRules, isWireSettingWriteLayer, parseModelRef, WIRE_GOAL_ACTIONS, OPERATION_TYPES, isOperationType, WIRE_PLUGIN_UPLOAD_FORM_FIELDS } from '@epoch-agent/protocol';
5
5
  import { resolve, join, relative, isAbsolute, sep, dirname, basename, extname } from 'path';
6
- import { existsSync, realpathSync, statSync, mkdirSync, createReadStream, readdirSync, accessSync, constants } from 'fs';
6
+ import { existsSync, realpathSync, statSync, mkdirSync, createReadStream, readdirSync, createWriteStream, accessSync, constants } from 'fs';
7
+ import { stat, readFile, rm } from 'fs/promises';
8
+ import { pipeline } from 'stream/promises';
7
9
  import { homedir } from 'os';
8
10
  import { execFile, spawn } from 'child_process';
9
- import { stat, readFile } from 'fs/promises';
10
11
  import { fileURLToPath } from 'url';
11
12
 
12
13
  // src/index.ts
@@ -271,6 +272,22 @@ function toWireSessionWorkspace(bound) {
271
272
  skippedInstructionFiles: bound.projectContext.skippedInstructions
272
273
  };
273
274
  }
275
+ function projectInventoryOf(workspaces, sessionId, trustRequest) {
276
+ const root = workspaces.of(sessionId)?.workspace.root;
277
+ return {
278
+ inventory: root === void 0 ? EMPTY_INVENTORY : workspaces.inventoryOf(root),
279
+ trustRequest
280
+ };
281
+ }
282
+ var EMPTY_INVENTORY = {
283
+ instructionFiles: [],
284
+ skills: 0,
285
+ roles: 0,
286
+ commands: 0,
287
+ hookFiles: [],
288
+ policies: 0,
289
+ settingsFiles: []
290
+ };
274
291
  function toWireBinding(state, bound) {
275
292
  if (state === "bound" && bound)
276
293
  return { state: "bound", workspace: toWireSessionWorkspace(bound) };
@@ -417,6 +434,7 @@ var SessionHub = class {
417
434
  registry;
418
435
  sinks = /* @__PURE__ */ new Set();
419
436
  observers = /* @__PURE__ */ new Set();
437
+ counted = /* @__PURE__ */ new Set();
420
438
  requestOwner = /* @__PURE__ */ new Map();
421
439
  seq = 0;
422
440
  graceTimer = null;
@@ -474,6 +492,9 @@ var SessionHub = class {
474
492
  get connectionCount() {
475
493
  return this.sinks.size;
476
494
  }
495
+ get present() {
496
+ return this.sinks.size + this.counted.size;
497
+ }
477
498
  subscribe(sink, lastEventId) {
478
499
  sink(this.connectionFrame({ type: "connected" }));
479
500
  if (lastEventId !== void 0) {
@@ -491,18 +512,24 @@ var SessionHub = class {
491
512
  if (released) return;
492
513
  released = true;
493
514
  this.sinks.delete(sink);
494
- if (this.sinks.size === 0) this.armGrace();
515
+ if (this.present === 0) this.armGrace();
495
516
  };
496
517
  }
497
- observe(sink) {
518
+ observe(sink, opts = {}) {
498
519
  if (this.closed) return () => {
499
520
  };
500
521
  this.observers.add(sink);
522
+ if (opts.counts === true) {
523
+ this.counted.add(sink);
524
+ this.clearGrace();
525
+ }
501
526
  let released = false;
502
527
  return () => {
503
528
  if (released) return;
504
529
  released = true;
505
530
  this.observers.delete(sink);
531
+ const wasPresent = this.counted.delete(sink);
532
+ if (wasPresent && this.present === 0) this.armGrace();
506
533
  };
507
534
  }
508
535
  start(sessionId, message, decorate) {
@@ -558,6 +585,10 @@ var SessionHub = class {
558
585
  if (this.closed) return;
559
586
  this.publish(sessionId, { type: "tasks-changed" });
560
587
  }
588
+ notifySessionsChanged(sessionId) {
589
+ if (this.closed) return;
590
+ this.publish(sessionId, { type: "sessions-changed" });
591
+ }
561
592
  shutdown() {
562
593
  if (this.closed) return;
563
594
  this.closed = true;
@@ -565,6 +596,7 @@ var SessionHub = class {
565
596
  this.reap();
566
597
  this.sinks.clear();
567
598
  this.observers.clear();
599
+ this.counted.clear();
568
600
  }
569
601
  publish(sessionId, event) {
570
602
  const frame = { seq: ++this.seq, sessionId, ts: this.clock(), event };
@@ -628,7 +660,7 @@ var SessionHub = class {
628
660
  }
629
661
  drainQueue(entry) {
630
662
  if (this.closed || !this.registry.has(entry.session.sessionId)) return;
631
- if (this.sinks.size === 0) {
663
+ if (this.present === 0) {
632
664
  entry.queue.length = 0;
633
665
  return;
634
666
  }
@@ -649,7 +681,7 @@ var SessionHub = class {
649
681
  this.graceTimer = null;
650
682
  }
651
683
  reap() {
652
- if (this.sinks.size > 0 && !this.closed) return;
684
+ if (this.present > 0 && !this.closed) return;
653
685
  for (const [, entry] of this.registry.all()) {
654
686
  entry.queue.length = 0;
655
687
  if (entry.state === "idle") continue;
@@ -767,6 +799,54 @@ async function readJsonBody(req, limit = JSON_BODY_LIMIT, lang) {
767
799
  };
768
800
  }
769
801
  }
802
+ var PLUGIN_UPLOAD_BODY_LIMIT = 50 * 1024 * 1024;
803
+ async function readRawBody(req, limit, lang) {
804
+ const chunks = [];
805
+ let size = 0;
806
+ try {
807
+ for await (const chunk of req) {
808
+ const buf = chunk;
809
+ size += buf.length;
810
+ if (size > limit) {
811
+ req.destroy();
812
+ return { ok: false, status: 413, message: t("web.body_too_large", { limit }, lang) };
813
+ }
814
+ chunks.push(buf);
815
+ }
816
+ } catch (err) {
817
+ return {
818
+ ok: false,
819
+ status: 400,
820
+ message: t("web.body_read_failed", { reason: describe(err) }, lang)
821
+ };
822
+ }
823
+ if (size === 0) {
824
+ return { ok: false, status: 400, message: t("web.body_empty", void 0, lang) };
825
+ }
826
+ return { ok: true, value: Buffer.concat(chunks) };
827
+ }
828
+ async function streamBodyToFile(req, target, lang) {
829
+ const sink = createWriteStream(target, { flags: "wx" });
830
+ try {
831
+ await pipeline(req, sink);
832
+ } catch (err) {
833
+ await rm(target, { force: true }).catch(() => void 0);
834
+ return {
835
+ ok: false,
836
+ status: 400,
837
+ message: t("web.body_read_failed", { reason: describe(err) }, lang)
838
+ };
839
+ }
840
+ try {
841
+ return { ok: true, bytes: (await stat(target)).size };
842
+ } catch (err) {
843
+ return {
844
+ ok: false,
845
+ status: 500,
846
+ message: t("web.body_read_failed", { reason: describe(err) }, lang)
847
+ };
848
+ }
849
+ }
770
850
  function describe(err) {
771
851
  return err instanceof Error ? err.message : String(err);
772
852
  }
@@ -933,7 +1013,7 @@ function estimateIndexTokens(text) {
933
1013
  return Math.ceil(text.length / 4);
934
1014
  }
935
1015
  function toWireMcp(server) {
936
- const state = server.needsLogin ? "needs-login" : server.connected ? "connected" : "failed";
1016
+ const state = mcpConnectorState(server);
937
1017
  return {
938
1018
  name: server.name,
939
1019
  state,
@@ -943,7 +1023,7 @@ function toWireMcp(server) {
943
1023
  source: server.source
944
1024
  };
945
1025
  }
946
- function collectCapabilities(runtime, tools, bound, role) {
1026
+ function collectCapabilities(runtime, tools, bound, role, project) {
947
1027
  const residency = runtime.skillIndexResidency(role);
948
1028
  return {
949
1029
  roles: runtime.agentRoles.map((r) => toWireRole(r, runtime.agentRoleNotices)),
@@ -952,7 +1032,10 @@ function collectCapabilities(runtime, tools, bound, role) {
952
1032
  mcpServers: runtime.mcpServers.map(toWireMcp),
953
1033
  project: {
954
1034
  root: bound?.workspace.root ?? null,
955
- trusted: bound?.trust.trusted ?? false
1035
+ trusted: bound?.trust.trusted ?? false,
1036
+ execTrusted: bound?.trust.exec ?? false,
1037
+ inventory: project.inventory,
1038
+ trustRequest: project.trustRequest
956
1039
  }
957
1040
  };
958
1041
  }
@@ -1022,6 +1105,81 @@ function replaceLeadingText(message, prompt) {
1022
1105
  const head = { type: "text", text: prompt };
1023
1106
  return [head, ...message.slice(1)];
1024
1107
  }
1108
+ async function uploadAttachment(ctx, req, res, sessionId, lang) {
1109
+ const raw = nameParam(req.url);
1110
+ if (raw === void 0) {
1111
+ return sendError(res, 400, "bad-request", t("web.attachment_name_required", void 0, lang));
1112
+ }
1113
+ const control = ctx.runtime.attachments;
1114
+ let placed;
1115
+ try {
1116
+ placed = control.place(sessionId, raw);
1117
+ } catch (err) {
1118
+ return sendError(res, 500, "attachment-failed", describe2(err));
1119
+ }
1120
+ if (!placed) {
1121
+ return sendError(
1122
+ res,
1123
+ 400,
1124
+ "bad-request",
1125
+ t("web.attachment_name_invalid", { name: raw }, lang)
1126
+ );
1127
+ }
1128
+ const out = await streamBodyToFile(req, placed.path, lang);
1129
+ if (!out.ok) return sendError(res, out.status, "bad-body", out.message);
1130
+ control.prune(sessionId);
1131
+ return sendJson(res, 200, {
1132
+ name: placed.name,
1133
+ bytes: out.bytes
1134
+ });
1135
+ }
1136
+ function attachUploads(ctx, sessionId, names, usedBytes = 0) {
1137
+ const parts = [];
1138
+ const receipts = [];
1139
+ let used = usedBytes;
1140
+ for (const name of names) {
1141
+ const part = readOne(ctx, sessionId, name, used);
1142
+ parts.push(part);
1143
+ if (part.text !== void 0) used += utf8ByteLength(part.text);
1144
+ const { type: _type, text: _text, ...rest } = part;
1145
+ receipts.push(rest);
1146
+ }
1147
+ return { parts, receipts, usedBytes: used };
1148
+ }
1149
+ function readOne(ctx, sessionId, name, usedBytes) {
1150
+ const remaining = MAX_ATTACH_TOTAL_BYTES - usedBytes;
1151
+ const path = ctx.runtime.attachments.resolve(sessionId, name) ?? name;
1152
+ if (remaining <= 0) return { type: "file", path, omitted: "over-limit" };
1153
+ const budget = Math.min(MAX_ATTACH_BYTES, remaining);
1154
+ const out = ctx.runtime.attachments.read(sessionId, name);
1155
+ if (!out.ok) {
1156
+ return {
1157
+ type: "file",
1158
+ path,
1159
+ omitted: out.reason,
1160
+ ...out.bytes === void 0 ? {} : { bytes: out.bytes }
1161
+ };
1162
+ }
1163
+ if (out.bytes <= budget) {
1164
+ return { type: "file", path: out.path, text: out.text, bytes: out.bytes };
1165
+ }
1166
+ const { text, truncatedTo } = truncateToBudget(new TextEncoder().encode(out.text), budget);
1167
+ return { type: "file", path: out.path, text, truncatedTo, bytes: out.bytes };
1168
+ }
1169
+ function nameParam(url) {
1170
+ let raw;
1171
+ try {
1172
+ raw = new URL(url ?? "/", "http://127.0.0.1").searchParams.get("name");
1173
+ } catch {
1174
+ return void 0;
1175
+ }
1176
+ return raw && raw.length > 0 ? raw : void 0;
1177
+ }
1178
+ function describe2(err) {
1179
+ return err instanceof Error ? err.message : String(err);
1180
+ }
1181
+ // src/mentions.ts
1182
+ var EMPTY_MENTIONS = { files: [], sessions: [] };
1025
1183
  var MAX_CANDIDATES = 20;
1026
1184
  var MAX_CANDIDATE_LIMIT = 50;
1027
1185
  function visibilityOf(ctx, sessionId) {
@@ -1103,16 +1261,18 @@ function listFileCandidates(ctx, res, sessionId, rawUrl, lang) {
1103
1261
  truncated: result.truncated
1104
1262
  });
1105
1263
  }
1106
- function attachMentions(ctx, sessionId, message) {
1264
+ function attachMentions(ctx, sessionId, message, uploads = []) {
1107
1265
  const text = leadingText2(message);
1108
- if (text === null) return { message, references: [], files: [] };
1109
- const mentions = extractAllMentions(text);
1110
- if (mentions.files.length === 0 && mentions.sessions.length === 0) {
1266
+ const mentions = text === null ? EMPTY_MENTIONS : extractAllMentions(text);
1267
+ if (uploads.length === 0 && mentions.files.length === 0 && mentions.sessions.length === 0) {
1111
1268
  return { message, references: [], files: [] };
1112
1269
  }
1113
1270
  const parts = [];
1114
1271
  let used = 0;
1115
- const fileReceipts = [];
1272
+ const uploaded = attachUploads(ctx, sessionId, uploads, used);
1273
+ parts.push(...uploaded.parts);
1274
+ used = uploaded.usedBytes;
1275
+ const fileReceipts = [...uploaded.receipts];
1116
1276
  for (const [index, path] of mentions.files.entries()) {
1117
1277
  const part = index >= MAX_ATTACH_FILES ? { type: "file", path, omitted: "over-limit" } : readOneFile(ctx, sessionId, path, used);
1118
1278
  parts.push(part);
@@ -1249,11 +1409,14 @@ function toWirePolicy(policy) {
1249
1409
  }));
1250
1410
  return { dirs, projectSkipped: policy.projectSkipped, skippedDir: policy.skippedDir };
1251
1411
  }
1252
- function toWireSecurityWorkspace(bound) {
1412
+ function toWireSecurityWorkspace(bound, project) {
1253
1413
  return {
1254
1414
  ...toWireSessionWorkspace(bound),
1255
1415
  trustLevel: bound.trust.level,
1256
- unloadedExtraInstructionFiles: bound.unloadedInstructions
1416
+ unloadedExtraInstructionFiles: bound.unloadedInstructions,
1417
+ execTrusted: bound.trust.exec,
1418
+ inventory: project.inventory,
1419
+ trustRequest: project.trustRequest
1257
1420
  };
1258
1421
  }
1259
1422
  function toWireTrustList(known, workspaces, currentRoot) {
@@ -1264,11 +1427,13 @@ function toWireTrustList(known, workspaces, currentRoot) {
1264
1427
  name: w.name,
1265
1428
  level: decision.level,
1266
1429
  trusted: decision.trusted,
1267
- current: currentRoot !== null && w.root === currentRoot
1430
+ execTrusted: decision.exec,
1431
+ current: currentRoot !== null && w.root === currentRoot,
1432
+ exists: existsSync(w.root)
1268
1433
  };
1269
1434
  });
1270
1435
  }
1271
- function collectSecurity(runtime, workspaces, bound, session) {
1436
+ function collectSecurity(runtime, workspaces, bound, session, project) {
1272
1437
  const perms = runtime.permissions;
1273
1438
  return {
1274
1439
  sandbox: runtime.isolation ? toWireSandbox(runtime.isolation, runtime.config.sandbox?.terminal !== false) : null,
@@ -1281,7 +1446,7 @@ function collectSecurity(runtime, workspaces, bound, session) {
1281
1446
  managed: toWireManaged(perms.managed()),
1282
1447
  cached: perms.approvals().map(toWireApproval)
1283
1448
  } : PERMISSIONS_UNAVAILABLE,
1284
- workspace: bound ? toWireSecurityWorkspace(bound) : null,
1449
+ workspace: bound ? toWireSecurityWorkspace(bound, project) : null,
1285
1450
  knownWorkspaces: toWireTrustList(workspaces.known(), workspaces, bound?.workspace.root ?? null),
1286
1451
  policy: toWirePolicy(runtime.policy),
1287
1452
  audit: perms ? toWireAudit(perms.audit()) : { rows: [], dropped: 0 }
@@ -1451,6 +1616,7 @@ function config(ctx, res, lang) {
1451
1616
  usageScope: runtime.usageScope,
1452
1617
  compression: runtime.compression,
1453
1618
  ...runtime.config.budget?.maxCostUsd !== void 0 ? { maxCostUsd: runtime.config.budget.maxCostUsd } : {},
1619
+ ...runtime.config.display?.currency !== void 0 ? { currency: runtime.config.display.currency } : {},
1454
1620
  ...runtime.config.brand !== void 0 ? { brand: runtime.config.brand } : {},
1455
1621
  ...runtime.config.sidebarMenu !== void 0 ? { sidebarMenu: runtime.config.sidebarMenu } : {},
1456
1622
  ...runtime.config.userMenu !== void 0 ? { userMenu: runtime.config.userMenu } : {},
@@ -1514,6 +1680,7 @@ async function deleteSession(ctx, res, sessionId, lang) {
1514
1680
  }
1515
1681
  const outcome = await catalog.delete(sessionId);
1516
1682
  if (!outcome.deleted && !inHub) return sendUnknownSession(res, sessionId, lang);
1683
+ ctx.hub.notifySessionsChanged(sessionId);
1517
1684
  sendJson(res, 200, outcome);
1518
1685
  }
1519
1686
  async function patchSession(ctx, req, res, sessionId, lang) {
@@ -1566,7 +1733,9 @@ async function postMessage(ctx, req, res, sessionId, lang) {
1566
1733
  }
1567
1734
  const body = await readJsonBody(req, void 0, lang);
1568
1735
  if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
1569
- const message = parseUserContent(asRecord(body.value).message);
1736
+ const uploads = parseAttachmentNames(asRecord(body.value)["attachments"]);
1737
+ const parsed = parseUserContent(asRecord(body.value).message);
1738
+ const message = parsed ?? (uploads.length > 0 ? [] : null);
1570
1739
  if (message === null) {
1571
1740
  return sendError(res, 400, "bad-message", t("web.bad_message", void 0, lang));
1572
1741
  }
@@ -1577,7 +1746,7 @@ async function postMessage(ctx, req, res, sessionId, lang) {
1577
1746
  return sendError(res, mapped.status, mapped.code, mapped.message);
1578
1747
  }
1579
1748
  const expansion = expandUserContent(ctx.runtime, message, live?.model ?? null, picked.role);
1580
- const attached = attachMentions(ctx, sessionId, expansion.message);
1749
+ const attached = attachMentions(ctx, sessionId, expansion.message, uploads);
1581
1750
  const started = ctx.hub.start(sessionId, attached.message, expansion.decorate);
1582
1751
  if (started.ok) {
1583
1752
  return sendJson(res, 202, {
@@ -1668,7 +1837,8 @@ function listCapabilities(ctx, res, sessionId, lang) {
1668
1837
  ctx.runtime,
1669
1838
  ctx.runtime.tools,
1670
1839
  ctx.runtime.workspaces.of(sessionId),
1671
- ctx.runtime.sessionFactory.get(sessionId)?.role?.name ?? null
1840
+ ctx.runtime.sessionFactory.get(sessionId)?.role?.name ?? null,
1841
+ projectInventoryOf(ctx.runtime.workspaces, sessionId, ctx.trustRequest !== void 0)
1672
1842
  );
1673
1843
  sendJson(res, 200, payload);
1674
1844
  }
@@ -1687,7 +1857,8 @@ function getSecurity(ctx, res, sessionId, lang) {
1687
1857
  ctx.runtime,
1688
1858
  ctx.runtime.workspaces,
1689
1859
  ctx.runtime.workspaces.of(sessionId),
1690
- ctx.runtime.sessionFactory.get(sessionId)?.permissions ?? null
1860
+ ctx.runtime.sessionFactory.get(sessionId)?.permissions ?? null,
1861
+ projectInventoryOf(ctx.runtime.workspaces, sessionId, ctx.trustRequest !== void 0)
1691
1862
  );
1692
1863
  sendJson(res, 200, payload);
1693
1864
  }
@@ -1757,6 +1928,10 @@ function parseUserContent(raw) {
1757
1928
  if (Array.isArray(raw) && raw.length > 0) return raw;
1758
1929
  return null;
1759
1930
  }
1931
+ function parseAttachmentNames(raw) {
1932
+ if (!Array.isArray(raw)) return [];
1933
+ return raw.filter((name) => typeof name === "string" && name.length > 0);
1934
+ }
1760
1935
  async function revokeApprovalCache(ctx, req, res, sessionId, lang) {
1761
1936
  if (!ctx.hub.has(sessionId)) return sendUnknownSession(res, sessionId, lang);
1762
1937
  const perms = ctx.runtime.permissions;
@@ -2286,10 +2461,11 @@ function sessionHasImages(session) {
2286
2461
  }
2287
2462
  function getModel(ctx, res, sessionId, _lang) {
2288
2463
  const { runtime } = ctx;
2289
- const selection = runtime.sessionFactory.get(sessionId)?.model.get();
2464
+ const live = runtime.sessionFactory.get(sessionId);
2465
+ const selection = live?.model.get();
2290
2466
  sendJson(res, 200, {
2291
2467
  selection: selection ? toWireSelection(selection) : null,
2292
- configured: runtime.config.model || null
2468
+ configured: (live ? live.model.configured() : runtime.config.model) || null
2293
2469
  });
2294
2470
  }
2295
2471
  async function setModel(ctx, req, res, sessionId, lang) {
@@ -2437,9 +2613,18 @@ function toWireCounts(counts) {
2437
2613
  mcpServers: counts.mcpServers
2438
2614
  };
2439
2615
  }
2440
- function toWirePluginEntry(entry) {
2616
+ function displayNamesOf(control) {
2617
+ const out = /* @__PURE__ */ new Map();
2618
+ for (const hit of control.search()) {
2619
+ const shown = hit.entry.displayName;
2620
+ if (shown !== void 0 && shown !== "") out.set(`${hit.marketplace}/${hit.entry.name}`, shown);
2621
+ }
2622
+ return out;
2623
+ }
2624
+ function toWirePluginEntry(entry, displayNames) {
2441
2625
  return {
2442
2626
  name: entry.name,
2627
+ displayName: displayNames.get(`${entry.marketplace}/${entry.name}`) ?? null,
2443
2628
  version: entry.version,
2444
2629
  source: entry.source,
2445
2630
  sourceType: entry.sourceType,
@@ -2554,8 +2739,9 @@ function pluginsPayload(control) {
2554
2739
  const installedMap = new Map(
2555
2740
  installed.map((p) => [p.name, { version: p.version, linked: p.linked }])
2556
2741
  );
2742
+ const shown = displayNamesOf(control);
2557
2743
  return {
2558
- installed: installed.map(toWirePluginEntry),
2744
+ installed: installed.map((p) => toWirePluginEntry(p, shown)),
2559
2745
  hits: control.search().map((hit) => toWirePluginHit(hit, installedMap)),
2560
2746
  marketplaces: control.marketplaces().map(toWireMarketplace),
2561
2747
  pendingRestart: control.pendingRestart,
@@ -2619,7 +2805,7 @@ function sendAction(res, control, outcome) {
2619
2805
  const fresh = "record" in outcome ? control.list().find((p) => p.name === outcome.record.name) : void 0;
2620
2806
  sendJson(res, 200, {
2621
2807
  ok: true,
2622
- entry: fresh ? toWirePluginEntry(fresh) : null,
2808
+ entry: fresh ? toWirePluginEntry(fresh, displayNamesOf(control)) : null,
2623
2809
  pendingRestart: control.pendingRestart,
2624
2810
  detail: "detail" in outcome ? outcome.detail : null,
2625
2811
  reason: null
@@ -2651,6 +2837,51 @@ async function installLocalPlugin(ctx, req, res, lang) {
2651
2837
  await control.installLocal(fieldOf(payload, "path"), fieldOf(payload, "token"), lang)
2652
2838
  );
2653
2839
  }
2840
+ async function stagePlugin(ctx, req, res, lang) {
2841
+ if (refusedByLan(ctx, res, lang)) return;
2842
+ const control = controlOf(ctx, res, lang);
2843
+ if (!control) return;
2844
+ const asJson = (req.headers["content-type"] ?? "").toLowerCase().includes("application/json");
2845
+ if (asJson) {
2846
+ const body = await readJsonBody(req, void 0, lang);
2847
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2848
+ return sendStage(
2849
+ res,
2850
+ await control.stage({ kind: "path", path: fieldOf(asRecord(body.value), "path") }, lang)
2851
+ );
2852
+ }
2853
+ const raw = await readRawBody(req, PLUGIN_UPLOAD_BODY_LIMIT, lang);
2854
+ if (!raw.ok) return sendError(res, raw.status, "bad-body", raw.message);
2855
+ return sendStage(
2856
+ res,
2857
+ await control.stage({ kind: "bytes", name: nameParam2(req), data: raw.value }, lang)
2858
+ );
2859
+ }
2860
+ function nameParam2(req) {
2861
+ try {
2862
+ return new URL(req.url ?? "/", "http://127.0.0.1").searchParams.get("name") ?? "";
2863
+ } catch {
2864
+ return "";
2865
+ }
2866
+ }
2867
+ function sendStage(res, outcome) {
2868
+ const payload = outcome.ok ? {
2869
+ ok: true,
2870
+ path: outcome.path,
2871
+ name: outcome.name,
2872
+ version: outcome.version,
2873
+ detail: null,
2874
+ reason: null
2875
+ } : {
2876
+ ok: false,
2877
+ path: null,
2878
+ name: null,
2879
+ version: null,
2880
+ detail: outcome.detail,
2881
+ reason: outcome.reason
2882
+ };
2883
+ sendJson(res, 200, payload);
2884
+ }
2654
2885
  async function updateInstalledPlugin(ctx, req, res, lang) {
2655
2886
  if (refusedByLan(ctx, res, lang)) return;
2656
2887
  const control = controlOf(ctx, res, lang);
@@ -2784,7 +3015,8 @@ function toWireProvider(option, lang) {
2784
3015
  label: providerLabel(option, lang),
2785
3016
  envVar: option.envVar,
2786
3017
  hasKey: option.hasKey,
2787
- keyHint: option.keyHint
3018
+ keyHint: option.keyHint,
3019
+ current: option.current
2788
3020
  };
2789
3021
  }
2790
3022
  function toWireSuggestions(view) {
@@ -3349,6 +3581,33 @@ async function removeSkill(ctx, req, res, lang) {
3349
3581
  reason: null
3350
3582
  });
3351
3583
  }
3584
+ function refusedByLan2(ctx, res, lang) {
3585
+ if (!ctx.lanExposed) return false;
3586
+ sendError(res, 403, "skill-stage-lan-exposed", t("web.skill_stage_lan_exposed", void 0, lang));
3587
+ return true;
3588
+ }
3589
+ async function stageSkillDrop(ctx, req, res, lang) {
3590
+ if (refusedByLan2(ctx, res, lang)) return;
3591
+ const raw = await readRawBody(req, PLUGIN_UPLOAD_BODY_LIMIT, lang);
3592
+ if (!raw.ok) return sendError(res, raw.status, "bad-body", raw.message);
3593
+ const outcome = await ctx.runtime.skillStage.stage(
3594
+ { name: nameParam3(req), data: raw.value },
3595
+ lang
3596
+ );
3597
+ sendJson(res, 200, {
3598
+ ok: outcome.ok,
3599
+ path: outcome.ok ? outcome.path : null,
3600
+ detail: outcome.ok ? null : outcome.detail,
3601
+ reason: outcome.ok ? null : outcome.reason
3602
+ });
3603
+ }
3604
+ function nameParam3(req) {
3605
+ try {
3606
+ return new URL(req.url ?? "/", "http://127.0.0.1").searchParams.get("name") ?? "";
3607
+ } catch {
3608
+ return "";
3609
+ }
3610
+ }
3352
3611
  // src/sse.ts
3353
3612
  var DEFAULT_HEARTBEAT_MS = 15e3;
3354
3613
  function parseLastEventId(req) {
@@ -3832,9 +4091,9 @@ var VIEWABLE = /* @__PURE__ */ new Map([
3832
4091
  [".webm", { kind: "video", mime: "video/webm" }],
3833
4092
  [".mov", { kind: "video", mime: "video/quicktime" }]
3834
4093
  ]);
3835
- function kindOf(stat2) {
3836
- if (stat2.kind === "text") return "text";
3837
- return VIEWABLE.get(extname(stat2.rel).toLowerCase())?.kind ?? "opaque";
4094
+ function kindOf(stat3) {
4095
+ if (stat3.kind === "text") return "text";
4096
+ return VIEWABLE.get(extname(stat3.rel).toLowerCase())?.kind ?? "opaque";
3838
4097
  }
3839
4098
  function toWireRefusal(reason) {
3840
4099
  return reason;
@@ -3882,15 +4141,15 @@ function statFiles(ctx, res, sessionId, rawUrl, lang) {
3882
4141
  const asked = params.getAll("path").filter((p) => p.trim() !== "");
3883
4142
  const taken = asked.slice(0, WIRE_FILE_STAT_MAX);
3884
4143
  const files = taken.map((path) => {
3885
- const stat2 = ctx.runtime.workspaceFiles.statFile(path, root);
3886
- if (!stat2.ok) return { path, ok: false, refusal: toWireRefusal(stat2.reason) };
4144
+ const stat3 = ctx.runtime.workspaceFiles.statFile(path, root);
4145
+ if (!stat3.ok) return { path, ok: false, refusal: toWireRefusal(stat3.reason) };
3887
4146
  return {
3888
4147
  path,
3889
4148
  ok: true,
3890
- kind: kindOf(stat2),
3891
- bytes: stat2.bytes,
3892
- mtimeMs: stat2.mtimeMs,
3893
- openable: ctx.sameMachine && isSystemOpenable(stat2.rel)
4149
+ kind: kindOf(stat3),
4150
+ bytes: stat3.bytes,
4151
+ mtimeMs: stat3.mtimeMs,
4152
+ openable: ctx.sameMachine && isSystemOpenable(stat3.rel)
3894
4153
  };
3895
4154
  });
3896
4155
  sendJson(res, 200, {
@@ -3904,18 +4163,18 @@ function readFileText(ctx, res, sessionId, rawUrl, lang) {
3904
4163
  if (root === null) return;
3905
4164
  const path = singlePath(res, rawUrl, lang);
3906
4165
  if (path === null) return;
3907
- const stat2 = ctx.runtime.workspaceFiles.statFile(path, root);
3908
- if (!stat2.ok) {
3909
- const refusal = toWireRefusal(stat2.reason);
4166
+ const stat3 = ctx.runtime.workspaceFiles.statFile(path, root);
4167
+ if (!stat3.ok) {
4168
+ const refusal = toWireRefusal(stat3.reason);
3910
4169
  return sendError(res, statusFor(refusal), refusal, describeRefusal(refusal, lang));
3911
4170
  }
3912
- if (stat2.kind !== "text") {
4171
+ if (stat3.kind !== "text") {
3913
4172
  return sendError(res, 415, "not-text", t("web.file_view_not_text", void 0, lang));
3914
4173
  }
3915
- if (stat2.bytes > MAX_TEXT_BYTES) {
4174
+ if (stat3.bytes > MAX_TEXT_BYTES) {
3916
4175
  return sendError(res, 413, "too-large", describeRefusal("too-large", lang));
3917
4176
  }
3918
- const out = ctx.runtime.workspaceFiles.readFile(stat2.rel, root);
4177
+ const out = ctx.runtime.workspaceFiles.readFile(stat3.rel, root);
3919
4178
  if (!out.ok) {
3920
4179
  const refusal = out.reason === "binary" ? "unreadable" : out.reason;
3921
4180
  return sendError(res, statusFor(refusal), refusal, describeRefusal(refusal, lang));
@@ -3931,26 +4190,26 @@ function readFileBytes(ctx, res, sessionId, rawUrl, headOnly, lang) {
3931
4190
  if (root === null) return;
3932
4191
  const path = singlePath(res, rawUrl, lang);
3933
4192
  if (path === null) return;
3934
- const stat2 = ctx.runtime.workspaceFiles.statFile(path, root);
3935
- if (!stat2.ok) {
3936
- const refusal = toWireRefusal(stat2.reason);
4193
+ const stat3 = ctx.runtime.workspaceFiles.statFile(path, root);
4194
+ if (!stat3.ok) {
4195
+ const refusal = toWireRefusal(stat3.reason);
3937
4196
  return sendError(res, statusFor(refusal), refusal, describeRefusal(refusal, lang));
3938
4197
  }
3939
- const known = VIEWABLE.get(extname(stat2.rel).toLowerCase());
3940
- const inline = known !== void 0 && stat2.kind === "binary";
4198
+ const known = VIEWABLE.get(extname(stat3.rel).toLowerCase());
4199
+ const inline = known !== void 0 && stat3.kind === "binary";
3941
4200
  const headers = {
3942
4201
  "Content-Type": inline ? known.mime : "application/octet-stream",
3943
4202
  "X-Content-Type-Options": "nosniff",
3944
- "Content-Disposition": inline ? "inline" : `attachment; filename*=UTF-8''${encodeURIComponent(basenameOf(stat2.rel))}`
4203
+ "Content-Disposition": inline ? "inline" : `attachment; filename*=UTF-8''${encodeURIComponent(basenameOf(stat3.rel))}`
3945
4204
  };
3946
4205
  if (inline && known.kind === "pdf") headers["Content-Security-Policy"] = "sandbox";
3947
4206
  if (headOnly) {
3948
4207
  return sendEmpty(res, 200, {
3949
4208
  ...headers,
3950
- "Content-Length": String(stat2.bytes)
4209
+ "Content-Length": String(stat3.bytes)
3951
4210
  });
3952
4211
  }
3953
- sendFile(res, stat2.absPath, headers);
4212
+ sendFile(res, stat3.absPath, headers);
3954
4213
  }
3955
4214
  function basenameOf(rel) {
3956
4215
  const cut = rel.lastIndexOf("/");
@@ -4001,6 +4260,26 @@ async function openFileWithSystemApp(ctx, req, res, sessionId, lang) {
4001
4260
  }
4002
4261
  sendJson(res, 200, { ok: true });
4003
4262
  }
4263
+ async function forgetWorkspace(ctx, sessionId, req, res, lang) {
4264
+ const body = await readJsonBody(req, void 0, lang);
4265
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
4266
+ if (!conformsToWire(body.value, WIRE_FORGET_WORKSPACE_FIELDS)) {
4267
+ return sendError(res, 400, "bad-request", t("web.forget_bad_request", {}, lang));
4268
+ }
4269
+ const { root } = body.value;
4270
+ const workspaces = ctx.runtime.workspaces;
4271
+ if (!workspaces.known().some((w) => w.root === root)) {
4272
+ return sendError(res, 404, "unknown-workspace", t("web.forget_unknown", {}, lang));
4273
+ }
4274
+ const current = workspaces.of(sessionId)?.workspace.root;
4275
+ if (current !== void 0 && current === root) {
4276
+ return sendError(res, 409, "workspace-in-use", t("web.forget_in_use", {}, lang));
4277
+ }
4278
+ workspaces.forget(root);
4279
+ sendJson(res, 200, {
4280
+ known: toWireTrustList(workspaces.known(), workspaces, current ?? null)
4281
+ });
4282
+ }
4004
4283
  var GIT_TIMEOUT_MS = 1e4;
4005
4284
  var GIT_MAX_BUFFER = 16 * 1024 * 1024;
4006
4285
  function runGit(cwd, args) {
@@ -4111,7 +4390,7 @@ async function gitDiff(root) {
4111
4390
  for (const item of picked) {
4112
4391
  const old = await gitSide(root, item.oldSha, sizes);
4113
4392
  const fresh = await worktreeSide(join(root, ...item.path.split("/")));
4114
- files.push(describe2(item.path, old, fresh));
4393
+ files.push(describe3(item.path, old, fresh));
4115
4394
  }
4116
4395
  return { source: "git", root, files, truncated, notes };
4117
4396
  }
@@ -4157,7 +4436,7 @@ async function checkpointDiff(opts) {
4157
4436
  for (const absolute of picked) {
4158
4437
  const fresh = await worktreeSide(absolute);
4159
4438
  files.push(
4160
- describe2(displayPath(opts.workDir, absolute), ABSENT, fresh, {
4439
+ describe3(displayPath(opts.workDir, absolute), ABSENT, fresh, {
4161
4440
  status: fresh.kind === "absent" ? "deleted" : "modified",
4162
4441
  omitted: "no-snapshot"
4163
4442
  })
@@ -4195,7 +4474,7 @@ async function worktreeSide(absolute) {
4195
4474
  return ABSENT;
4196
4475
  }
4197
4476
  }
4198
- function describe2(path, old, fresh, override = {}) {
4477
+ function describe3(path, old, fresh, override = {}) {
4199
4478
  const status = override.status ?? (old.kind === "absent" ? "added" : fresh.kind === "absent" ? "deleted" : "modified");
4200
4479
  const oldBytes = bytesOf(old);
4201
4480
  const newBytes = bytesOf(fresh);
@@ -4278,6 +4557,7 @@ async function createSession(ctx, req, res, lang) {
4278
4557
  }
4279
4558
  ctx.hub.register(outcome.live.session);
4280
4559
  if (defer) ctx.runtime.workspaces.defer(outcome.live.sessionId);
4560
+ if (outcome.created) ctx.hub.notifySessionsChanged(outcome.live.sessionId);
4281
4561
  sendJson(res, 200, {
4282
4562
  sessionId: outcome.live.sessionId,
4283
4563
  created: outcome.created,
@@ -4362,6 +4642,36 @@ async function sessionDiff(ctx, res, sessionId, _lang) {
4362
4642
  })
4363
4643
  );
4364
4644
  }
4645
+ function isKnownRoot(ctx, sessionId, root) {
4646
+ if (ctx.runtime.workspaces.of(sessionId)?.workspace.root === root) return true;
4647
+ return ctx.runtime.workspaces.known().some((w) => w.root === root);
4648
+ }
4649
+ async function requestTrust(ctx, sessionId, req, res, lang) {
4650
+ const ask = ctx.trustRequest;
4651
+ if (ask === void 0) {
4652
+ return sendError(res, 404, "trust-request-unavailable", t("web.trust_unavailable", {}, lang));
4653
+ }
4654
+ const body = await readJsonBody(req, void 0, lang);
4655
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
4656
+ if (!conformsToWire(body.value, WIRE_TRUST_REQUEST_FIELDS)) {
4657
+ return sendError(res, 400, "bad-request", t("web.trust_bad_request", {}, lang));
4658
+ }
4659
+ const { root, want } = body.value;
4660
+ if (!isKnownRoot(ctx, sessionId, root)) {
4661
+ return sendError(res, 400, "unknown-workspace", t("web.trust_unknown_root", {}, lang));
4662
+ }
4663
+ try {
4664
+ await ask({ root, want: want === "exec" ? "exec" : "read" });
4665
+ } catch (err) {
4666
+ return sendError(
4667
+ res,
4668
+ 500,
4669
+ "trust-request-failed",
4670
+ t("web.trust_failed", { detail: err instanceof Error ? err.message : String(err) }, lang)
4671
+ );
4672
+ }
4673
+ sendJson(res, 200, { asked: true });
4674
+ }
4365
4675
  // src/routes.ts
4366
4676
  var DENY_KEY = {
4367
4677
  "missing-credential": "web.route.deny_missing_credential",
@@ -4369,10 +4679,17 @@ var DENY_KEY = {
4369
4679
  "bad-origin": "web.route.deny_bad_origin",
4370
4680
  "bad-host": "web.route.deny_bad_host"
4371
4681
  };
4682
+ var log = createLogger("server-route");
4372
4683
  function createRequestHandler(opts) {
4373
4684
  return (req, res) => {
4374
4685
  const lang = langOf(req.url);
4375
- dispatch(opts, req, res, lang).catch(() => {
4686
+ dispatch(opts, req, res, lang).catch((err) => {
4687
+ log.error("unhandled handler exception", {
4688
+ method: req.method ?? "",
4689
+ path: (req.url ?? "").split("?")[0],
4690
+ error: err instanceof Error ? err.message : String(err),
4691
+ stack: err instanceof Error ? err.stack : void 0
4692
+ });
4376
4693
  if (!res.headersSent) {
4377
4694
  sendError(res, 500, "internal", t("web.route.internal_error", void 0, lang));
4378
4695
  } else res.destroy();
@@ -4457,6 +4774,9 @@ async function handleApi(opts, req, res, method, segments, lang) {
4457
4774
  if (resource === "skills" && first === "import" && second === void 0 && method === "POST") {
4458
4775
  return importSkills(ctx, req, res, lang);
4459
4776
  }
4777
+ if (resource === "skills" && first === "stage" && second === void 0 && method === "POST") {
4778
+ return stageSkillDrop(ctx, req, res, lang);
4779
+ }
4460
4780
  if (resource === "skills" && first === "remove" && second === void 0 && method === "POST") {
4461
4781
  return removeSkill(ctx, req, res, lang);
4462
4782
  }
@@ -4499,6 +4819,9 @@ async function handleApi(opts, req, res, method, segments, lang) {
4499
4819
  if (resource === "plugins" && first === "refresh" && second === void 0 && method === "POST") {
4500
4820
  return refreshPlugins(ctx, res, lang);
4501
4821
  }
4822
+ if (resource === "plugins" && first === "stage" && second === void 0 && method === "POST") {
4823
+ return stagePlugin(ctx, req, res, lang);
4824
+ }
4502
4825
  if (resource === "providers" && first === void 0 && isRead(method)) {
4503
4826
  return listProviders(ctx, res, lang);
4504
4827
  }
@@ -4580,6 +4903,9 @@ async function handleSessions(opts, req, res, method, sessionId, action, detail,
4580
4903
  if (method === "POST") return postMessage(ctx, req, res, sessionId, lang);
4581
4904
  return methodNotAllowed(res, "GET, POST", lang);
4582
4905
  }
4906
+ if (action === "attachments" && method === "POST") {
4907
+ return uploadAttachment(ctx, req, res, sessionId, lang);
4908
+ }
4583
4909
  if (action === "abort" && method === "POST") return abortSession(ctx, res, sessionId, lang);
4584
4910
  if (action === "reference-candidates" && isRead(method)) {
4585
4911
  return listReferenceCandidates(ctx, res, sessionId, req.url, lang);
@@ -4638,6 +4964,12 @@ async function handleSessions(opts, req, res, method, sessionId, action, detail,
4638
4964
  if (action === "permission" && method === "POST") {
4639
4965
  return setPermission(ctx, req, res, sessionId, lang);
4640
4966
  }
4967
+ if (action === "trust-request" && method === "POST") {
4968
+ return requestTrust(ctx, sessionId, req, res, lang);
4969
+ }
4970
+ if (action === "workspaces" && method === "POST" && detail === "forget") {
4971
+ return forgetWorkspace(ctx, sessionId, req, res, lang);
4972
+ }
4641
4973
  if (action === "approval-cache" && method === "POST") {
4642
4974
  return revokeApprovalCache(ctx, req, res, sessionId, lang);
4643
4975
  }
@@ -4752,7 +5084,7 @@ async function createWebServer(opts) {
4752
5084
  host: binding.host,
4753
5085
  token: binding.token,
4754
5086
  ...webRoot === void 0 ? {} : { webRoot },
4755
- observe: (sink) => hub.observe(sink),
5087
+ observe: (sink, opts2) => hub.observe(sink, opts2 ?? {}),
4756
5088
  snapshot: () => hub.listSessions(),
4757
5089
  close: makeCloser(server, hub, opts.runtime, unsubscribeTasks)
4758
5090
  }
@@ -4772,7 +5104,8 @@ function buildHandler(opts, hub, binding, port, webRoot) {
4772
5104
  artifactsRoot: opts.artifactsRoot ?? opts.runtime.artifactsRoot,
4773
5105
  lanExposed: binding.lanExposed,
4774
5106
  nativeDirPicker: resolveNativeDirPicker(machineFacts),
4775
- sameMachine: isSameMachine(machineFacts)
5107
+ sameMachine: isSameMachine(machineFacts),
5108
+ ...opts.onTrustRequest === void 0 ? {} : { trustRequest: opts.onTrustRequest }
4776
5109
  },
4777
5110
  guard: createAuthGuard({ token: binding.token, port, lanExposed: binding.lanExposed }),
4778
5111
  ...webRoot === void 0 ? {} : { webRoot },