@epoch-agent/server 0.18.0 → 0.20.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,13 +1,13 @@
1
1
  import { createServer } from 'http';
2
2
  import { createLogger, t, ApprovalRelay, QuestionRelay, serializeStream, withRoleScope, withModelScope, withToolScope, nextRunAt, observeBackgroundTasks, skillIndexDescription, SANDBOX_EXCLUDES, SANDBOX_COVERS, canProbeGit, guardSpawn, isSystemOpenable, backgroundTaskOutput, listBackgroundTasks, providerLabel, MAX_REASON_CHARS, MAX_OBJECTIVE_CHARS, MAX_GOAL_MAX_ROUNDS, DEFAULT_GOAL_MAX_ROUNDS, SCHEDULE_DEFAULT_ALLOWLIST, SCHEDULE_FORM_BUDGET_USD, SCHEDULE_DEFAULTS, coalesceRecording, readRecording } from '@epoch-agent/runtime';
3
- import { timingSafeEqual, randomBytes } from 'crypto';
3
+ import { timingSafeEqual, randomBytes, randomUUID } from 'crypto';
4
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, conformsToWire, WIRE_LAST_SESSION_FIELDS, isQuestionAnswerMap, isProviderType, apiKeyEnvVar, WIRE_FILE_STAT_MAX, WIRE_SETTING_WRITE_LAYERS, isWirePlanAction, WIRE_PLAN_ACTIONS, isPermissionLevel, PERMISSION_LEVELS, 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
6
  import { existsSync, realpathSync, statSync, mkdirSync, createReadStream, readdirSync, createWriteStream, accessSync, constants } from 'fs';
7
7
  import { stat, readFile, rm } from 'fs/promises';
8
8
  import { pipeline } from 'stream/promises';
9
- import { homedir } from 'os';
10
9
  import { execFile, spawn } from 'child_process';
10
+ import { homedir } from 'os';
11
11
  import { fileURLToPath } from 'url';
12
12
 
13
13
  // src/index.ts
@@ -137,6 +137,70 @@ function firstScreenUrl(host, port, token, ui = {}) {
137
137
  if (ui.lang) params.push(`${UI_LANG_PARAM}=${encodeURIComponent(ui.lang)}`);
138
138
  return `http://${authority}:${port}/?${params.join("&")}`;
139
139
  }
140
+ var HOST_COMPOSE_TTL_MS = 10 * 6e4;
141
+ var HostComposeStore = class {
142
+ items = /* @__PURE__ */ new Map();
143
+ now;
144
+ ttlMs;
145
+ newId;
146
+ constructor(opts = {}) {
147
+ this.now = opts.now ?? Date.now;
148
+ this.ttlMs = opts.ttlMs ?? HOST_COMPOSE_TTL_MS;
149
+ this.newId = opts.newId ?? (() => `hc_${randomUUID()}`);
150
+ }
151
+ record(input) {
152
+ const now = this.now();
153
+ this.sweep(now);
154
+ for (const [id, item2] of this.items) {
155
+ if (item2.sessionId === input.sessionId && item2.takenAt === void 0) this.items.delete(id);
156
+ }
157
+ const item = {
158
+ id: this.newId(),
159
+ sessionId: input.sessionId,
160
+ text: input.text,
161
+ autoSend: input.autoSend,
162
+ createdAt: now
163
+ };
164
+ this.items.set(item.id, item);
165
+ return item;
166
+ }
167
+ pending() {
168
+ this.sweep(this.now());
169
+ let newest = null;
170
+ for (const item of this.items.values()) {
171
+ if (item.takenAt !== void 0) continue;
172
+ if (newest === null || item.createdAt >= newest.createdAt) newest = item;
173
+ }
174
+ return newest;
175
+ }
176
+ take(id) {
177
+ const now = this.now();
178
+ this.sweep(now, id);
179
+ const item = this.items.get(id);
180
+ if (item === void 0) return "unknown";
181
+ if (now - item.createdAt >= this.ttlMs) {
182
+ this.items.delete(id);
183
+ return "expired";
184
+ }
185
+ if (item.takenAt !== void 0) return "taken";
186
+ item.takenAt = now;
187
+ return "ok";
188
+ }
189
+ sweep(now, keep) {
190
+ for (const [id, item] of this.items) {
191
+ if (id === keep) continue;
192
+ if (now - item.createdAt >= this.ttlMs) this.items.delete(id);
193
+ }
194
+ }
195
+ };
196
+ function toWireHostCompose(item) {
197
+ return {
198
+ id: item.id,
199
+ sessionId: item.sessionId,
200
+ text: item.text,
201
+ autoSend: item.autoSend
202
+ };
203
+ }
140
204
  // src/ring.ts
141
205
  var DEFAULT_RING_CAPACITY = 512;
142
206
  var EnvelopeRing = class {
@@ -601,6 +665,10 @@ var SessionHub = class {
601
665
  if (this.closed) return;
602
666
  this.publish(void 0, { type: "plugins-changed" });
603
667
  }
668
+ notifyHostCompose(sessionId, payload) {
669
+ if (this.closed) return;
670
+ this.publish(sessionId, { type: "host-compose", ...payload });
671
+ }
604
672
  shutdown() {
605
673
  if (this.closed) return;
606
674
  this.closed = true;
@@ -1663,12 +1731,19 @@ function lastSessionField(ctx) {
1663
1731
  if (!ctx.hub.has(recorded) && !ctx.runtime.sessions?.get(recorded)) return {};
1664
1732
  return { lastSessionId: recorded };
1665
1733
  }
1734
+ function hostComposeField(ctx) {
1735
+ const item = ctx.hostCompose.pending();
1736
+ if (!item) return {};
1737
+ if (!ctx.hub.has(item.sessionId) && !ctx.runtime.sessions?.get(item.sessionId)) return {};
1738
+ return { hostCompose: toWireHostCompose(item) };
1739
+ }
1666
1740
  function config(ctx, res, lang) {
1667
1741
  const { runtime } = ctx;
1668
1742
  const bound = runtime.workspaces.of(runtime.sessionId);
1669
1743
  sendJson(res, 200, {
1670
1744
  sessionId: runtime.sessionId,
1671
1745
  ...lastSessionField(ctx),
1746
+ ...hostComposeField(ctx),
1672
1747
  model: runtime.config.model,
1673
1748
  permission: runtime.config.permission,
1674
1749
  workspace: bound ? toWireSessionWorkspace(bound) : null,
@@ -2378,970 +2453,1411 @@ function listBlockedGoals(ctx, res, _lang) {
2378
2453
  );
2379
2454
  sendJson(res, 200, { items });
2380
2455
  }
2381
- async function putLastSession(ctx, req, res, lang) {
2382
- const control = ctx.runtime.lastOpenSession;
2383
- if (!control) {
2384
- return sendError(
2385
- res,
2386
- 503,
2387
- "no-last-session-store",
2388
- t("web.last_session_unavailable", {}, lang)
2389
- );
2390
- }
2391
- const body = await readJsonBody(req, void 0, lang);
2392
- if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2393
- if (!conformsToWire(body.value, WIRE_LAST_SESSION_FIELDS)) {
2394
- return sendError(res, 400, "bad-request", t("web.last_session_bad_request", {}, lang));
2395
- }
2396
- const { sessionId } = body.value;
2397
- if (typeof sessionId !== "string" || sessionId.length === 0) {
2398
- return sendError(res, 400, "bad-request", t("web.last_session_bad_request", {}, lang));
2399
- }
2400
- if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
2401
- return sendUnknownSession(res, sessionId, lang);
2402
- }
2403
- const failure = control.set(sessionId);
2404
- if (failure) return sendError(res, 500, "write-failed", failure(lang));
2405
- sendJson(res, 200, { ok: true });
2406
- }
2407
- function readConfig(ctx, res, lang) {
2408
- if (ctx.lanExposed) return sendLanExposed(res, lang);
2409
- const outcome = ctx.runtime.mcp.readConfig();
2410
- if (!outcome.ok) {
2411
- return sendError(
2412
- res,
2413
- 409,
2414
- "mcp-config-unreadable",
2415
- t("web.mcp_config_unreadable", { detail: outcome.detail }, lang)
2456
+ var GIT_TIMEOUT_MS = 1e4;
2457
+ var GIT_MAX_BUFFER = 16 * 1024 * 1024;
2458
+ function runGit(cwd, args) {
2459
+ if (!canProbeGit()) return Promise.resolve({ ok: false, error: "git unavailable" });
2460
+ return new Promise((resolve4) => {
2461
+ guardSpawn(
2462
+ () => execFile(
2463
+ "git",
2464
+ [...args],
2465
+ { cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BUFFER, encoding: "buffer" },
2466
+ (err, stdout) => {
2467
+ if (err) return resolve4({ ok: false, error: err.message });
2468
+ resolve4({ ok: true, stdout });
2469
+ }
2470
+ ),
2471
+ (err) => resolve4({ ok: false, error: `${err.message} (cwd=${cwd})` })
2416
2472
  );
2417
- }
2418
- const { snapshot } = outcome;
2419
- sendJson(res, 200, {
2420
- configPath: snapshot.configPath,
2421
- text: snapshot.text,
2422
- exists: snapshot.exists,
2423
- revision: snapshot.revision,
2424
- issues: toWireIssues(snapshot.issues)
2425
2473
  });
2426
2474
  }
2427
- async function saveConfig(ctx, req, res, lang) {
2428
- if (ctx.lanExposed) return sendLanExposed(res, lang);
2429
- const body = await readJsonBody(req);
2430
- if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2431
- const payload = asRecord(body.value);
2432
- const text = payload["text"];
2433
- const revision = payload["revision"];
2434
- if (typeof text !== "string" || typeof revision !== "string") {
2435
- return sendError(
2436
- res,
2437
- 400,
2438
- "bad-mcp-config-body",
2439
- t("web.mcp_config_bad_body", void 0, lang)
2440
- );
2475
+ async function repoRoot(cwd) {
2476
+ const result = await runGit(cwd, ["rev-parse", "--show-toplevel"]);
2477
+ if (!result.ok) return null;
2478
+ const root = result.stdout.toString("utf-8").trim();
2479
+ return root.length > 0 ? root : null;
2480
+ }
2481
+ async function hasHead(root) {
2482
+ return (await runGit(root, ["rev-parse", "--verify", "--quiet", "HEAD"])).ok;
2483
+ }
2484
+ var NULL_SHA = /^0+$/;
2485
+ async function rawChanges(root) {
2486
+ const result = await runGit(root, ["diff", "--raw", "-z", "HEAD", "--"]);
2487
+ if (!result.ok) return [];
2488
+ return parseRawZ(result.stdout.toString("utf-8"));
2489
+ }
2490
+ function parseRawZ(text) {
2491
+ const tokens = text.split("\0");
2492
+ const changes = [];
2493
+ let i = 0;
2494
+ while (i < tokens.length) {
2495
+ const meta = tokens[i];
2496
+ if (meta === void 0 || !meta.startsWith(":")) {
2497
+ i += 1;
2498
+ continue;
2499
+ }
2500
+ const parts = meta.slice(1).split(" ");
2501
+ const oldSha = parts[2] ?? "";
2502
+ const status = (parts[4] ?? "").trim();
2503
+ const renamed = status.startsWith("R") || status.startsWith("C");
2504
+ const first = tokens[i + 1];
2505
+ const second = renamed ? tokens[i + 2] : void 0;
2506
+ i += renamed ? 3 : 2;
2507
+ if (first === void 0 || renamed && second === void 0) break;
2508
+ const path = renamed ? second : first;
2509
+ changes.push({
2510
+ status: status.charAt(0),
2511
+ path,
2512
+ ...renamed ? { oldPath: first } : {},
2513
+ oldSha: NULL_SHA.test(oldSha) ? null : oldSha
2514
+ });
2441
2515
  }
2442
- const outcome = ctx.runtime.mcp.writeConfig(text, revision, lang);
2443
- if (!outcome.ok) return sendError(res, ...saveFailure(outcome.reason, outcome.detail, lang));
2444
- sendJson(res, 200, {
2445
- configPath: outcome.configPath,
2446
- revision: outcome.revision,
2447
- issues: toWireIssues(outcome.issues)
2448
- });
2516
+ return changes;
2449
2517
  }
2450
- async function applyConfig(ctx, req, res, lang) {
2451
- if (ctx.lanExposed) return sendLanExposed(res, lang);
2452
- const body = await readJsonBody(req);
2453
- if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2454
- const revision = asRecord(body.value)["revision"];
2455
- if (typeof revision !== "string") {
2456
- return sendError(
2457
- res,
2458
- 400,
2459
- "bad-mcp-config-body",
2460
- t("web.mcp_config_bad_body", void 0, lang)
2461
- );
2518
+ async function treeBlobSizes(root) {
2519
+ const sizes = /* @__PURE__ */ new Map();
2520
+ const result = await runGit(root, ["ls-tree", "-r", "-l", "-z", "HEAD"]);
2521
+ if (!result.ok) return sizes;
2522
+ for (const record of splitZ(result.stdout.toString("utf-8"))) {
2523
+ const tab = record.indexOf(" ");
2524
+ if (tab < 0) continue;
2525
+ const columns = record.slice(0, tab).split(/\s+/).filter(Boolean);
2526
+ const sha = columns[2];
2527
+ const size = Number(columns[3]);
2528
+ if (columns[1] === "blob" && sha && Number.isFinite(size)) sizes.set(sha, size);
2462
2529
  }
2463
- const outcome = await ctx.runtime.mcp.applyConfig(revision, lang);
2464
- if (!outcome.ok) return sendError(res, ...applyFailure(outcome.reason, outcome.detail, lang));
2465
- sendJson(res, 200, {
2466
- configPath: outcome.configPath,
2467
- revision: outcome.revision,
2468
- issues: toWireIssues(outcome.issues),
2469
- changes: outcome.changes.map((change) => ({
2470
- name: change.name,
2471
- action: change.action,
2472
- ...change.status === void 0 ? {} : { server: toWireMcp(change.status) },
2473
- ...change.reason === void 0 ? {} : { reason: change.reason }
2474
- }))
2475
- });
2530
+ return sizes;
2476
2531
  }
2477
- function sendLanExposed(res, lang) {
2478
- sendError(res, 403, "mcp-config-lan-exposed", t("web.mcp_config_lan_exposed", void 0, lang));
2532
+ async function untrackedFiles(root) {
2533
+ const result = await runGit(root, ["ls-files", "-z", "--others", "--exclude-standard"]);
2534
+ if (!result.ok) return [];
2535
+ return splitZ(result.stdout.toString("utf-8"));
2479
2536
  }
2480
- function saveFailure(reason, detail, lang) {
2481
- if (reason === "stale") {
2482
- return [409, "mcp-config-stale", t("web.mcp_config_stale", void 0, lang)];
2537
+ async function trackedFiles(root) {
2538
+ const result = await runGit(root, ["ls-files", "-z"]);
2539
+ if (!result.ok) return [];
2540
+ return splitZ(result.stdout.toString("utf-8"));
2541
+ }
2542
+ async function catFileBlob(root, sha) {
2543
+ const result = await runGit(root, ["cat-file", "blob", sha]);
2544
+ return result.ok ? result.stdout : null;
2545
+ }
2546
+ function splitZ(text) {
2547
+ return text.split("\0").filter((s) => s.length > 0);
2548
+ }
2549
+ // src/workspace/diff.ts
2550
+ var MAX_DIFF_BYTES = 512 * 1024;
2551
+ var MAX_DIFF_FILES = 200;
2552
+ var BINARY_SNIFF_BYTES = 8e3;
2553
+ var ABSENT = { kind: "absent" };
2554
+ async function collectWorkspaceDiff(opts) {
2555
+ const root = await repoRoot(opts.workDir);
2556
+ return root === null ? await checkpointDiff(opts) : await gitDiff(root);
2557
+ }
2558
+ async function gitDiff(root) {
2559
+ const wanted = await collectGitPaths(root);
2560
+ const notes = [];
2561
+ const truncated = wanted.length > MAX_DIFF_FILES;
2562
+ const picked = truncated ? wanted.slice(0, MAX_DIFF_FILES) : wanted;
2563
+ if (truncated) notes.push("truncated");
2564
+ if (wanted.length === 0) notes.push("no-changes");
2565
+ const sizes = picked.some((p) => p.oldSha !== null) ? await treeBlobSizes(root) : /* @__PURE__ */ new Map();
2566
+ const files = [];
2567
+ for (const item of picked) {
2568
+ const old = await gitSide(root, item.oldSha, sizes);
2569
+ const fresh = await worktreeSide(join(root, ...item.path.split("/")));
2570
+ files.push(describe3(item.path, old, fresh));
2483
2571
  }
2484
- if (reason === "invalid-json") return [400, "mcp-config-invalid-json", detail];
2485
- return [409, "mcp-config-unwritable", t("web.mcp_config_unwritable", { detail }, lang)];
2572
+ return { source: "git", root, files, truncated, notes };
2486
2573
  }
2487
- function applyFailure(reason, detail, lang) {
2488
- if (reason === "unreadable") {
2489
- return [409, "mcp-config-unreadable", t("web.mcp_config_unreadable", { detail }, lang)];
2574
+ async function collectGitPaths(root) {
2575
+ const byPath = /* @__PURE__ */ new Map();
2576
+ if (await hasHead(root)) {
2577
+ for (const change of await rawChanges(root)) {
2578
+ if (change.oldPath !== void 0) {
2579
+ byPath.set(change.oldPath, { path: change.oldPath, oldSha: change.oldSha });
2580
+ }
2581
+ byPath.set(change.path, {
2582
+ path: change.path,
2583
+ oldSha: change.oldPath === void 0 ? change.oldSha : null
2584
+ });
2585
+ }
2586
+ } else {
2587
+ for (const path of await trackedFiles(root)) byPath.set(path, { path, oldSha: null });
2490
2588
  }
2491
- if (reason === "stale") {
2492
- return [409, "mcp-config-stale", t("web.mcp_config_stale", void 0, lang)];
2589
+ for (const path of await untrackedFiles(root)) {
2590
+ if (!byPath.has(path)) byPath.set(path, { path, oldSha: null });
2493
2591
  }
2494
- if (reason === "invalid-json") return [400, "mcp-config-invalid-json", detail];
2495
- return [409, "mcp-config-no-servers", t("web.mcp_config_no_servers", { detail }, lang)];
2592
+ return [...byPath.values()].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
2496
2593
  }
2497
- function toWireIssues(issues) {
2498
- return issues.map((issue) => ({ path: issue.path, message: issue.message }));
2594
+ async function gitSide(root, sha, sizes) {
2595
+ if (sha === null) return ABSENT;
2596
+ const known = sizes.get(sha);
2597
+ if (known !== void 0 && known > MAX_DIFF_BYTES) return { kind: "oversize", bytes: known };
2598
+ const data = await catFileBlob(root, sha);
2599
+ if (data === null) return { kind: "oversize", bytes: known ?? -1 };
2600
+ return { kind: "content", data };
2499
2601
  }
2500
- async function reconnectMcp(ctx, res, name, lang) {
2501
- const outcome = await ctx.runtime.mcp.reconnect(name);
2502
- if (!outcome.ok) {
2503
- if (outcome.reason === "unknown") {
2504
- return sendError(res, 404, "unknown-mcp-server", t("web.mcp_unknown_server", { name }, lang));
2505
- }
2506
- const status = {
2507
- ...outcome.status,
2508
- ...outcome.detail === void 0 ? {} : { lastError: outcome.detail }
2509
- };
2510
- return sendJson(res, 200, {
2511
- ok: false,
2512
- server: toWireMcp(status)
2513
- });
2602
+ async function checkpointDiff(opts) {
2603
+ const notes = ["not-a-git-repo"];
2604
+ const paths = await checkpointPaths(opts.checkpoints);
2605
+ if (paths.length === 0) {
2606
+ notes.push(opts.checkpoints === null ? "no-workspace-history" : "no-changes");
2607
+ return { source: "none", root: opts.workDir, files: [], truncated: false, notes };
2514
2608
  }
2515
- sendJson(res, 200, {
2516
- ok: true,
2517
- server: toWireMcp(outcome.status)
2518
- });
2519
- }
2520
- async function addMcp(ctx, req, res, lang) {
2521
- if (ctx.lanExposed) {
2522
- return sendError(
2523
- res,
2524
- 403,
2525
- "mcp-add-lan-exposed",
2526
- t("web.mcp_add_lan_exposed", void 0, lang)
2609
+ const truncated = paths.length > MAX_DIFF_FILES;
2610
+ const picked = truncated ? paths.slice(0, MAX_DIFF_FILES) : paths;
2611
+ if (truncated) notes.push("truncated");
2612
+ const files = [];
2613
+ for (const absolute of picked) {
2614
+ const fresh = await worktreeSide(absolute);
2615
+ files.push(
2616
+ describe3(displayPath(opts.workDir, absolute), ABSENT, fresh, {
2617
+ status: fresh.kind === "absent" ? "deleted" : "modified",
2618
+ omitted: "no-snapshot"
2619
+ })
2527
2620
  );
2528
2621
  }
2529
- const body = await readJsonBody(req);
2530
- if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2531
- const payload = asRecord(body.value);
2532
- const name = typeof payload["name"] === "string" ? payload["name"].trim() : "";
2533
- const transport = payload["transport"];
2534
- if (transport !== "stdio" && transport !== "sse" && transport !== "http") {
2535
- return sendError(res, 400, "bad-mcp-config", t("web.mcp_add_bad_transport", void 0, lang));
2536
- }
2537
- const outcome = await ctx.runtime.mcp.add({
2538
- name,
2539
- transport,
2540
- ...typeof payload["command"] === "string" ? { command: payload["command"].trim() } : {},
2541
- ...Array.isArray(payload["args"]) ? { args: stringList(payload["args"]) } : {},
2542
- ...typeof payload["url"] === "string" ? { url: payload["url"].trim() } : {},
2543
- ...payload["env"] === void 0 ? {} : { env: stringMap(payload["env"]) }
2544
- });
2545
- if (!outcome.ok) {
2546
- return sendError(res, ...addFailure(outcome.reason, outcome.detail, name, lang));
2547
- }
2548
- sendJson(res, 200, {
2549
- server: toWireMcp(outcome.status),
2550
- configPath: outcome.configPath
2551
- });
2622
+ return { source: "checkpoints", root: opts.workDir, files, truncated, notes };
2552
2623
  }
2553
- function addFailure(reason, detail, name, lang) {
2554
- if (reason === "invalid-name") {
2555
- return [400, "bad-mcp-name", t("web.mcp_add_bad_name", { name }, lang)];
2624
+ async function checkpointPaths(checkpoints) {
2625
+ if (!checkpoints) return [];
2626
+ const paths = /* @__PURE__ */ new Set();
2627
+ try {
2628
+ for (const summary of await checkpoints.list()) {
2629
+ const preview = await checkpoints.preview(summary.turnIndex);
2630
+ if (!preview) continue;
2631
+ for (const plan of [...preview.files, ...preview.conflicts]) {
2632
+ if (plan.action !== "skip") paths.add(plan.path);
2633
+ }
2634
+ }
2635
+ } catch {
2556
2636
  }
2557
- if (reason === "invalid-config") return [400, "bad-mcp-config", detail];
2558
- if (reason === "duplicate")
2559
- return [409, "mcp-name-taken", t("web.mcp_add_taken", { name }, lang)];
2560
- return [409, "mcp-config-unwritable", t("web.mcp_add_unwritable", { detail }, lang)];
2637
+ return [...paths].sort();
2561
2638
  }
2562
- function stringList(value) {
2563
- return value.filter((v) => typeof v === "string");
2639
+ function displayPath(workDir, absolute) {
2640
+ const rel = relative(workDir, absolute);
2641
+ if (rel.length === 0 || rel.startsWith("..") || isAbsolute(rel)) return absolute;
2642
+ return rel.split(sep).join("/");
2564
2643
  }
2565
- function stringMap(value) {
2566
- const out = {};
2567
- for (const [k, v] of Object.entries(asRecord(value))) {
2568
- if (typeof v === "string") out[k] = v;
2644
+ async function worktreeSide(absolute) {
2645
+ try {
2646
+ const info = await stat(absolute);
2647
+ if (!info.isFile()) return ABSENT;
2648
+ if (info.size > MAX_DIFF_BYTES) return { kind: "oversize", bytes: info.size };
2649
+ return { kind: "content", data: await readFile(absolute) };
2650
+ } catch {
2651
+ return ABSENT;
2569
2652
  }
2570
- return out;
2571
2653
  }
2572
- function toRef(raw) {
2573
- return parseModelRef(raw);
2654
+ function describe3(path, old, fresh, override = {}) {
2655
+ const status = override.status ?? (old.kind === "absent" ? "added" : fresh.kind === "absent" ? "deleted" : "modified");
2656
+ const oldBytes = bytesOf(old);
2657
+ const newBytes = bytesOf(fresh);
2658
+ const hard = omitReason(old, fresh);
2659
+ if (hard !== void 0) {
2660
+ return { path, status, oldContent: null, newContent: null, oldBytes, newBytes, omitted: hard };
2661
+ }
2662
+ return {
2663
+ path,
2664
+ status,
2665
+ oldContent: textOf(old),
2666
+ newContent: textOf(fresh),
2667
+ oldBytes,
2668
+ newBytes,
2669
+ ...override.omitted === void 0 ? {} : { omitted: override.omitted }
2670
+ };
2574
2671
  }
2575
- function toWireSelection(selection) {
2576
- return { provider: selection.provider, model: selection.model, origin: selection.origin };
2672
+ function bytesOf(side) {
2673
+ if (side.kind === "absent") return null;
2674
+ if (side.kind === "oversize") return side.bytes < 0 ? null : side.bytes;
2675
+ return side.data.byteLength;
2577
2676
  }
2578
- function sessionHasImages(session) {
2579
- const history = session?.getHistory() ?? [];
2580
- return history.some((m) => m.parts?.some((p) => p.type === "image"));
2677
+ function textOf(side) {
2678
+ return side.kind === "content" ? side.data.toString("utf-8") : null;
2581
2679
  }
2582
- function getModel(ctx, res, sessionId, _lang) {
2583
- const { runtime } = ctx;
2584
- const live = runtime.sessionFactory.get(sessionId);
2585
- const selection = live?.model.get();
2586
- sendJson(res, 200, {
2587
- selection: selection ? toWireSelection(selection) : null,
2588
- configured: (live ? live.model.configured() : runtime.config.model) || null
2589
- });
2680
+ function omitReason(old, fresh) {
2681
+ if (isBinary(old) || isBinary(fresh)) return "binary";
2682
+ if (old.kind === "oversize" || fresh.kind === "oversize") return "too-large";
2683
+ return void 0;
2590
2684
  }
2591
- async function setModel(ctx, req, res, sessionId, lang) {
2592
- const live = ctx.runtime.sessionFactory.get(sessionId);
2593
- if (!live) return sendUnknownSession(res, sessionId);
2594
- const control = live.model;
2685
+ function isBinary(side) {
2686
+ return side.kind === "content" && side.data.subarray(0, BINARY_SNIFF_BYTES).includes(0);
2687
+ }
2688
+ // src/workspace/handlers.ts
2689
+ async function createSession(ctx, req, res, lang) {
2595
2690
  const body = await readJsonBody(req);
2596
2691
  if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2597
- const input = readModelSetBody(asRecord(body.value));
2598
- if (!input.ok)
2599
- return sendError(res, 400, "bad-model", t("web.model_bad_request", void 0, lang));
2600
- const ref = input.model === null ? null : toRef(input.model);
2601
- if (ref === void 0) {
2602
- return sendError(res, 400, "bad-model", t("web.model_bad_request", void 0, lang));
2603
- }
2604
- const selectionCtx = {
2605
- hasImages: sessionHasImages(live.session),
2606
- ...input.usedTokens === void 0 ? {} : { usedTokens: input.usedTokens }
2607
- };
2608
- const result = ref === null ? control.reset(selectionCtx) : control.set(ref, selectionCtx);
2609
- const payload = result.ok ? {
2610
- ok: true,
2611
- selection: toWireSelection(result.selection),
2612
- contextLength: result.contextLength,
2613
- caveats: result.caveats
2614
- } : { ok: false, rejection: result.rejection };
2615
- sendJson(res, 200, payload);
2692
+ const outcome = await ensureSession(ctx, asRecord(body.value), lang);
2693
+ if (!outcome.ok) return sendError(res, outcome.status, outcome.code, outcome.message);
2694
+ sendJson(res, 200, {
2695
+ sessionId: outcome.sessionId,
2696
+ created: outcome.created,
2697
+ workspace: outcome.workspace
2698
+ });
2616
2699
  }
2617
- function readModelSetBody(body) {
2618
- const { model, usedTokens } = body;
2619
- if (model !== null && (typeof model !== "string" || model.trim() === "")) return { ok: false };
2620
- const used = typeof usedTokens === "number" && Number.isFinite(usedTokens) && usedTokens >= 0 ? Math.floor(usedTokens) : void 0;
2700
+ async function ensureSession(ctx, payload, lang) {
2701
+ const field = readWorkspaceField(payload, lang);
2702
+ if (!field.ok) return { ok: false, status: 400, code: "bad-workspace", message: field.message };
2703
+ const defer = payload["deferWorkspace"] === true;
2704
+ if (defer && field.dir !== null) {
2705
+ return {
2706
+ ok: false,
2707
+ status: 400,
2708
+ code: "bad-request",
2709
+ message: t("web.workspace_defer_with_root", void 0, lang)
2710
+ };
2711
+ }
2712
+ if (defer && payload["create"] !== true) {
2713
+ return {
2714
+ ok: false,
2715
+ status: 400,
2716
+ code: "bad-request",
2717
+ message: t("web.workspace_defer_needs_create", void 0, lang)
2718
+ };
2719
+ }
2720
+ if (field.dir === null && payload["create"] !== true) {
2721
+ const sessionId = ctx.runtime.sessionId;
2722
+ const bound = ctx.runtime.workspaces.of(sessionId);
2723
+ return {
2724
+ ok: true,
2725
+ sessionId,
2726
+ created: false,
2727
+ workspace: bound ? toWireSessionWorkspace(bound) : null
2728
+ };
2729
+ }
2730
+ const role = typeof payload["role"] === "string" ? payload["role"].trim() : "";
2731
+ const made = ctx.runtime.sessionFactory.create({
2732
+ workspace: field.dir,
2733
+ ...lang === void 0 ? {} : { lang },
2734
+ reuse: payload["create"] !== true,
2735
+ ...role === "" ? {} : { role }
2736
+ });
2737
+ if (!made.ok) {
2738
+ if (made.reason === "unavailable") return { ok: false, ...unavailableToHttp(lang) };
2739
+ if (made.reason === "unknown-role") {
2740
+ return { ok: false, ...unknownRoleToHttp(made.detail, lang) };
2741
+ }
2742
+ return { ok: false, ...bindFailureToHttp(made, lang) };
2743
+ }
2744
+ ctx.hub.register(made.live.session);
2745
+ if (defer) ctx.runtime.workspaces.defer(made.live.sessionId);
2746
+ if (made.created) ctx.hub.notifySessionsChanged(made.live.sessionId);
2621
2747
  return {
2622
2748
  ok: true,
2623
- model: model === null ? null : model.trim(),
2624
- ...used === void 0 ? {} : { usedTokens: used }
2749
+ sessionId: made.live.sessionId,
2750
+ created: made.created,
2751
+ workspace: made.live.workspace ? toWireSessionWorkspace(made.live.workspace) : null
2625
2752
  };
2626
2753
  }
2627
- function planModeState(ctx, sessionId) {
2628
- if (!ctx.runtime.plan) return null;
2629
- const plan = ctx.runtime.sessionFactory.get(sessionId)?.plan;
2630
- if (!plan) return { active: false, from: null };
2631
- return { active: plan.active(), from: plan.from() };
2754
+ function sessionWorkspace(ctx, res, sessionId, lang) {
2755
+ if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
2756
+ return sendUnknownSession(res, sessionId);
2757
+ }
2758
+ const state = ctx.runtime.workspaces.stateOf(sessionId);
2759
+ if (state !== "none") {
2760
+ return sendJson(res, 200, {
2761
+ binding: toWireBinding(state, ctx.runtime.workspaces.of(sessionId))
2762
+ });
2763
+ }
2764
+ if (ctx.hub.has(sessionId) || ctx.hub.isCooled(sessionId)) {
2765
+ return sendJson(res, 200, {
2766
+ binding: { state: "none" }
2767
+ });
2768
+ }
2769
+ sendError(res, 409, "not-live-session", t("web.workspace_not_live", void 0, lang));
2632
2770
  }
2633
- async function setPlanMode(ctx, req, res, sessionId, lang) {
2634
- const live = ctx.runtime.sessionFactory.get(sessionId);
2635
- if (!live) return sendUnknownSession(res, sessionId);
2636
- const plan = live.plan;
2637
- if (!plan) {
2638
- return sendError(res, 503, "no-plan-mode", t("web.plan_mode_unavailable", void 0, lang));
2771
+ async function bindWorkspace(ctx, req, res, sessionId, lang) {
2772
+ if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
2773
+ return sendUnknownSession(res, sessionId);
2639
2774
  }
2640
2775
  const body = await readJsonBody(req);
2641
2776
  if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2642
- const action = asRecord(body.value)["action"];
2643
- if (typeof action !== "string" || !isWirePlanAction(action)) {
2777
+ const parsed = readBindWorkspaceBody(asRecord(body.value), lang);
2778
+ if (!parsed.ok) return sendError(res, 400, "bad-workspace", parsed.message);
2779
+ const state = ctx.runtime.workspaces.stateOf(sessionId);
2780
+ if (state !== "unbound") {
2644
2781
  return sendError(
2645
2782
  res,
2646
- 400,
2647
- "bad-action",
2648
- t("web.plan_mode_bad_action", { actions: WIRE_PLAN_ACTIONS.join("/") }, lang)
2783
+ 409,
2784
+ "workspace-locked",
2785
+ t("web.workspace_already_decided", void 0, lang)
2649
2786
  );
2650
2787
  }
2651
- const changed = action === "enter" ? plan.enter().ok : plan.leave() !== null;
2788
+ const turn = ctx.hub.stateOf(sessionId);
2789
+ if (turn !== "idle") {
2790
+ return sendError(res, 409, "busy", t("web.workspace_turn_running", void 0, lang), {
2791
+ "X-Epoch-Turn-State": turn
2792
+ });
2793
+ }
2794
+ if (parsed.kind === "none") {
2795
+ ctx.runtime.workspaces.decideNone(sessionId);
2796
+ return sendJson(res, 200, {
2797
+ binding: { state: "none" }
2798
+ });
2799
+ }
2800
+ const outcome = ctx.runtime.workspaces.bind(sessionId, parsed.root, lang);
2801
+ if (!outcome.ok) {
2802
+ const mapped = bindFailureToHttp(outcome, lang);
2803
+ return sendError(res, mapped.status, mapped.code, mapped.message);
2804
+ }
2652
2805
  sendJson(res, 200, {
2653
- mode: { active: plan.active(), from: plan.from() },
2654
- changed
2806
+ binding: { state: "bound", workspace: toWireSessionWorkspace(outcome.bound) }
2655
2807
  });
2656
2808
  }
2657
- // src/permission.ts
2658
- var REFUSAL = {
2659
- "managed-bypass-disabled": "managed"
2660
- };
2661
- function blockedLevels(managed) {
2662
- return managed.bypassDisabled ? [{ level: "bypass", reason: "managed" }] : [];
2809
+ async function sessionDiff(ctx, res, sessionId, _lang) {
2810
+ if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
2811
+ return sendUnknownSession(res, sessionId);
2812
+ }
2813
+ const bound = ctx.runtime.workspaces.of(sessionId);
2814
+ if (!bound) {
2815
+ return sendJson(res, 200, {
2816
+ source: "none",
2817
+ root: "",
2818
+ files: [],
2819
+ truncated: false,
2820
+ notes: ["no-workspace"]
2821
+ });
2822
+ }
2823
+ sendJson(
2824
+ res,
2825
+ 200,
2826
+ await collectWorkspaceDiff({
2827
+ workDir: bound.workspace.root,
2828
+ checkpoints: checkpointsOf(ctx, sessionId)
2829
+ })
2830
+ );
2663
2831
  }
2664
- function permissionState(ctx, sessionId) {
2665
- const perms = ctx.runtime.sessionFactory.get(sessionId)?.permissions ?? null;
2666
- return {
2667
- level: perms?.level() ?? ctx.runtime.config.permission,
2668
- editable: perms !== null,
2669
- blocked: perms ? blockedLevels(perms.managed()) : [],
2670
- planMode: planModeState(ctx, sessionId)
2671
- };
2832
+ // src/host-compose.ts
2833
+ async function postHostCompose(ctx, req, res, lang) {
2834
+ const body = await readJsonBody(req, void 0, lang);
2835
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2836
+ const payload = asRecord(body.value);
2837
+ const sessionId = payload["sessionId"];
2838
+ if (sessionId !== void 0 && typeof sessionId !== "string") {
2839
+ return sendError(res, 400, "bad-request", t("web.host_compose_bad_request", void 0, lang));
2840
+ }
2841
+ if (sessionId !== void 0 && ["workspace", "create", "role", "deferWorkspace"].some((key) => payload[key] !== void 0)) {
2842
+ return sendError(res, 400, "bad-request", t("web.host_compose_exclusive", void 0, lang));
2843
+ }
2844
+ const rawText = payload["text"];
2845
+ if (rawText !== void 0 && typeof rawText !== "string") {
2846
+ return sendError(res, 400, "bad-request", t("web.host_compose_bad_request", void 0, lang));
2847
+ }
2848
+ const text = rawText?.trim() ?? "";
2849
+ if (rawText !== void 0 && text === "") {
2850
+ return sendError(res, 400, "bad-request", t("web.host_compose_blank_text", void 0, lang));
2851
+ }
2852
+ const autoSend = payload["autoSend"] === true;
2853
+ if (autoSend && text === "") {
2854
+ return sendError(
2855
+ res,
2856
+ 400,
2857
+ "bad-request",
2858
+ t("web.host_compose_autosend_needs_text", void 0, lang)
2859
+ );
2860
+ }
2861
+ let target;
2862
+ let created = false;
2863
+ if (typeof sessionId === "string") {
2864
+ if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
2865
+ return sendUnknownSession(res, sessionId, lang);
2866
+ }
2867
+ target = sessionId;
2868
+ } else {
2869
+ const outcome = await ensureSession(ctx, payload, lang);
2870
+ if (!outcome.ok) return sendError(res, outcome.status, outcome.code, outcome.message);
2871
+ target = outcome.sessionId;
2872
+ created = outcome.created;
2873
+ }
2874
+ const item = ctx.hostCompose.record({ sessionId: target, text, autoSend });
2875
+ ctx.hub.notifyHostCompose(target, { id: item.id, text, autoSend });
2876
+ sendJson(res, 200, {
2877
+ sessionId: target,
2878
+ created,
2879
+ composeId: item.id,
2880
+ expiresAt: item.createdAt + HOST_COMPOSE_TTL_MS
2881
+ });
2672
2882
  }
2673
- function getPermission(ctx, res, sessionId, _lang) {
2674
- if (!ctx.hub.has(sessionId)) return sendUnknownSession(res, sessionId);
2675
- sendJson(res, 200, permissionState(ctx, sessionId));
2883
+ function getHostCompose(ctx, res) {
2884
+ const item = ctx.hostCompose.pending();
2885
+ sendJson(res, 200, {
2886
+ compose: item === null ? null : toWireHostCompose(item)
2887
+ });
2676
2888
  }
2677
- async function setPermission(ctx, req, res, sessionId, lang) {
2678
- const live = ctx.runtime.sessionFactory.get(sessionId);
2679
- if (!live) return sendUnknownSession(res, sessionId);
2680
- const perms = live.permissions;
2681
- if (!perms) {
2889
+ function takeHostCompose(ctx, res, id) {
2890
+ const result = ctx.hostCompose.take(id);
2891
+ sendJson(
2892
+ res,
2893
+ 200,
2894
+ result === "ok" ? { ok: true } : { ok: false, reason: result }
2895
+ );
2896
+ }
2897
+ async function putLastSession(ctx, req, res, lang) {
2898
+ const control = ctx.runtime.lastOpenSession;
2899
+ if (!control) {
2682
2900
  return sendError(
2683
2901
  res,
2684
2902
  503,
2685
- "no-permission-layer",
2686
- t("web.permission_unavailable", void 0, lang)
2903
+ "no-last-session-store",
2904
+ t("web.last_session_unavailable", {}, lang)
2905
+ );
2906
+ }
2907
+ const body = await readJsonBody(req, void 0, lang);
2908
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2909
+ if (!conformsToWire(body.value, WIRE_LAST_SESSION_FIELDS)) {
2910
+ return sendError(res, 400, "bad-request", t("web.last_session_bad_request", {}, lang));
2911
+ }
2912
+ const { sessionId } = body.value;
2913
+ if (typeof sessionId !== "string" || sessionId.length === 0) {
2914
+ return sendError(res, 400, "bad-request", t("web.last_session_bad_request", {}, lang));
2915
+ }
2916
+ if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
2917
+ return sendUnknownSession(res, sessionId, lang);
2918
+ }
2919
+ const failure = control.set(sessionId);
2920
+ if (failure) return sendError(res, 500, "write-failed", failure(lang));
2921
+ sendJson(res, 200, { ok: true });
2922
+ }
2923
+ function readConfig(ctx, res, lang) {
2924
+ if (ctx.lanExposed) return sendLanExposed(res, lang);
2925
+ const outcome = ctx.runtime.mcp.readConfig();
2926
+ if (!outcome.ok) {
2927
+ return sendError(
2928
+ res,
2929
+ 409,
2930
+ "mcp-config-unreadable",
2931
+ t("web.mcp_config_unreadable", { detail: outcome.detail }, lang)
2687
2932
  );
2688
2933
  }
2934
+ const { snapshot } = outcome;
2935
+ sendJson(res, 200, {
2936
+ configPath: snapshot.configPath,
2937
+ text: snapshot.text,
2938
+ exists: snapshot.exists,
2939
+ revision: snapshot.revision,
2940
+ issues: toWireIssues(snapshot.issues)
2941
+ });
2942
+ }
2943
+ async function saveConfig(ctx, req, res, lang) {
2944
+ if (ctx.lanExposed) return sendLanExposed(res, lang);
2689
2945
  const body = await readJsonBody(req);
2690
2946
  if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2691
- const level = asRecord(body.value)["level"];
2692
- if (typeof level !== "string" || !isPermissionLevel(level)) {
2947
+ const payload = asRecord(body.value);
2948
+ const text = payload["text"];
2949
+ const revision = payload["revision"];
2950
+ if (typeof text !== "string" || typeof revision !== "string") {
2693
2951
  return sendError(
2694
2952
  res,
2695
2953
  400,
2696
- "bad-level",
2697
- t("web.permission_bad_level", { levels: PERMISSION_LEVELS.join("/") }, lang)
2954
+ "bad-mcp-config-body",
2955
+ t("web.mcp_config_bad_body", void 0, lang)
2698
2956
  );
2699
2957
  }
2700
- const before = perms.level();
2701
- const result = perms.setLevel(level, { remember: true });
2702
- const state = permissionState(ctx, sessionId);
2703
- const payload = result.ok ? {
2704
- ok: true,
2705
- changed: state.level !== before,
2706
- state,
2707
- ...result.notRemembered ? { notRemembered: result.notRemembered } : {}
2708
- } : { ok: false, refused: REFUSAL[result.reason], changed: false, state };
2709
- sendJson(res, 200, payload);
2958
+ const outcome = ctx.runtime.mcp.writeConfig(text, revision, lang);
2959
+ if (!outcome.ok) return sendError(res, ...saveFailure(outcome.reason, outcome.detail, lang));
2960
+ sendJson(res, 200, {
2961
+ configPath: outcome.configPath,
2962
+ revision: outcome.revision,
2963
+ issues: toWireIssues(outcome.issues)
2964
+ });
2710
2965
  }
2711
- // src/plan.ts
2712
- function getPlan(ctx, res, sessionId, _lang) {
2713
- if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
2714
- return sendUnknownSession(res, sessionId);
2966
+ async function applyConfig(ctx, req, res, lang) {
2967
+ if (ctx.lanExposed) return sendLanExposed(res, lang);
2968
+ const body = await readJsonBody(req);
2969
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2970
+ const revision = asRecord(body.value)["revision"];
2971
+ if (typeof revision !== "string") {
2972
+ return sendError(
2973
+ res,
2974
+ 400,
2975
+ "bad-mcp-config-body",
2976
+ t("web.mcp_config_bad_body", void 0, lang)
2977
+ );
2715
2978
  }
2716
- const live = ctx.runtime.sessionFactory.get(sessionId);
2717
- const markdown = live ? live.plan?.approvedPlan() ?? null : ctx.runtime.sessionStore?.loadPlan(sessionId) ?? null;
2979
+ const outcome = await ctx.runtime.mcp.applyConfig(revision, lang);
2980
+ if (!outcome.ok) return sendError(res, ...applyFailure(outcome.reason, outcome.detail, lang));
2718
2981
  sendJson(res, 200, {
2719
- plan: normalize(markdown),
2720
- mode: planModeState(ctx, sessionId)
2982
+ configPath: outcome.configPath,
2983
+ revision: outcome.revision,
2984
+ issues: toWireIssues(outcome.issues),
2985
+ changes: outcome.changes.map((change) => ({
2986
+ name: change.name,
2987
+ action: change.action,
2988
+ ...change.status === void 0 ? {} : { server: toWireMcp(change.status) },
2989
+ ...change.reason === void 0 ? {} : { reason: change.reason }
2990
+ }))
2721
2991
  });
2722
2992
  }
2723
- function normalize(markdown) {
2724
- return markdown?.trim() ? markdown : null;
2993
+ function sendLanExposed(res, lang) {
2994
+ sendError(res, 403, "mcp-config-lan-exposed", t("web.mcp_config_lan_exposed", void 0, lang));
2725
2995
  }
2726
- function toWireCounts(counts) {
2727
- return {
2728
- commands: counts.commands,
2729
- roles: counts.roles,
2730
- skills: counts.skills,
2731
- hooks: counts.hooks,
2732
- denyRules: counts.denyRules,
2733
- mcpServers: counts.mcpServers
2734
- };
2996
+ function saveFailure(reason, detail, lang) {
2997
+ if (reason === "stale") {
2998
+ return [409, "mcp-config-stale", t("web.mcp_config_stale", void 0, lang)];
2999
+ }
3000
+ if (reason === "invalid-json") return [400, "mcp-config-invalid-json", detail];
3001
+ return [409, "mcp-config-unwritable", t("web.mcp_config_unwritable", { detail }, lang)];
2735
3002
  }
2736
- function displayNamesOf(control) {
2737
- const out = /* @__PURE__ */ new Map();
2738
- for (const hit of control.search()) {
2739
- const shown = hit.entry.displayName;
2740
- if (shown !== void 0 && shown !== "") out.set(`${hit.marketplace}/${hit.entry.name}`, shown);
3003
+ function applyFailure(reason, detail, lang) {
3004
+ if (reason === "unreadable") {
3005
+ return [409, "mcp-config-unreadable", t("web.mcp_config_unreadable", { detail }, lang)];
2741
3006
  }
2742
- return out;
3007
+ if (reason === "stale") {
3008
+ return [409, "mcp-config-stale", t("web.mcp_config_stale", void 0, lang)];
3009
+ }
3010
+ if (reason === "invalid-json") return [400, "mcp-config-invalid-json", detail];
3011
+ return [409, "mcp-config-no-servers", t("web.mcp_config_no_servers", { detail }, lang)];
2743
3012
  }
2744
- function toWirePluginEntry(entry, displayNames) {
2745
- return {
2746
- name: entry.name,
2747
- displayName: displayNames.get(`${entry.marketplace}/${entry.name}`) ?? null,
2748
- version: entry.version,
2749
- source: entry.source,
2750
- sourceType: entry.sourceType,
2751
- path: entry.path,
2752
- linked: entry.linked,
2753
- enabled: entry.enabled,
2754
- installedAt: entry.installedAt,
2755
- marketplace: entry.marketplace ?? null,
2756
- active: entry.active,
2757
- counts: entry.counts ? toWireCounts(entry.counts) : null,
2758
- updateAllowed: entry.updateAllowed,
2759
- uninstallAllowed: entry.uninstallAllowed
2760
- };
3013
+ function toWireIssues(issues) {
3014
+ return issues.map((issue) => ({ path: issue.path, message: issue.message }));
2761
3015
  }
2762
- function toWirePluginHit(hit, installed) {
2763
- const mine = installed.get(hit.entry.name);
2764
- return {
2765
- marketplace: hit.marketplace,
2766
- ref: hit.ref,
2767
- name: hit.entry.name,
2768
- description: hit.entry.description ?? "",
2769
- category: hit.entry.category ?? null,
2770
- keywords: [...hit.entry.keywords ?? []],
2771
- sourceType: hit.sourceType ?? null,
2772
- installable: hit.installable,
2773
- installed: mine !== void 0,
2774
- author: hit.entry.author?.name ?? null,
2775
- downloads: hit.entry.downloads ?? null,
2776
- likes: hit.entry.likes ?? null,
2777
- displayName: hit.entry.displayName ?? null,
2778
- icon: hit.entry.icon ?? null,
2779
- version: hit.entry.version ?? null,
2780
- changelog: hit.entry.changelog ?? null,
2781
- size: hit.entry.size ?? null,
2782
- homepage: hit.entry.homepage ?? null,
2783
- visibility: hit.entry.visibility ?? null,
2784
- status: hit.entry.status ?? null,
2785
- updateAvailable: mine !== void 0 && !mine.linked && hit.entry.version !== void 0 && hit.entry.version !== mine.version,
2786
- installAllowed: hit.installAllowed,
2787
- updateAllowed: mine?.updateAllowed ?? false,
2788
- menu: hit.menu.map((item) => ({
2789
- id: item.id,
2790
- label: item.label,
2791
- danger: item.danger === true
2792
- }))
2793
- };
2794
- }
2795
- function toWireMarketplace(market) {
2796
- return {
2797
- name: market.name,
2798
- source: market.source,
2799
- entries: market.entries,
2800
- addedAt: market.addedAt,
2801
- local: market.local,
2802
- categories: (market.categories ?? []).map(toWireCategory)
2803
- };
2804
- }
2805
- function toWireCategory(node) {
2806
- return {
2807
- id: node.id,
2808
- label: node.label ?? node.id,
2809
- children: (node.children ?? []).map(toWireCategory)
2810
- };
2811
- }
2812
- function toWirePreview2(preview) {
2813
- const { manifest, source, inventory } = preview;
2814
- return {
2815
- name: manifest.name,
2816
- version: manifest.version,
2817
- description: manifest.description ?? "",
2818
- source: source.raw,
2819
- sourceType: source.type,
2820
- checksum: source.sha256 ?? null,
2821
- author: manifest.author?.name ?? null,
2822
- homepage: manifest.homepage ?? null,
2823
- commands: [...inventory.commands],
2824
- roles: [...inventory.roles],
2825
- skills: [...inventory.skills],
2826
- hooks: inventory.hooks.map((h) => ({ type: h.type, count: h.count })),
2827
- denyRules: inventory.denyRules,
2828
- mcpServers: [...inventory.mcpServers],
2829
- ignoredBuckets: [...inventory.ignoredBuckets],
2830
- hasJsTools: inventory.jsTools,
2831
- conflict: preview.conflict ? { name: preview.conflict.name, version: preview.conflict.version } : null
2832
- };
2833
- }
2834
- function controlOf(ctx, res, lang) {
2835
- const control = ctx.runtime.plugins;
2836
- if (!control) {
2837
- sendError(res, 503, "no-plugin-control", t("web.plugin_not_available", void 0, lang));
2838
- return null;
2839
- }
2840
- return control;
2841
- }
2842
- function refusedByLan(ctx, res, lang) {
2843
- if (!ctx.lanExposed) return false;
2844
- sendError(
2845
- res,
2846
- 403,
2847
- "plugin-write-lan-exposed",
2848
- t("web.plugin_write_lan_exposed", void 0, lang)
2849
- );
2850
- return true;
2851
- }
2852
- function fieldOf(payload, key) {
2853
- const value = payload[key];
2854
- return typeof value === "string" ? value.trim() : "";
2855
- }
2856
- function uploadFormOf(payload) {
2857
- const form = {};
2858
- for (const name of WIRE_PLUGIN_UPLOAD_FORM_FIELDS) {
2859
- const raw = fieldOf(payload, name);
2860
- if (raw === "") continue;
2861
- if (name === "visibility") {
2862
- if (raw === "private" || raw === "public") form[name] = raw;
2863
- continue;
2864
- }
2865
- form[name] = raw;
2866
- }
2867
- return form;
2868
- }
2869
- function listPlugins(ctx, res, lang) {
2870
- const control = controlOf(ctx, res, lang);
2871
- if (!control) return;
2872
- sendJson(res, 200, pluginsPayload(control));
2873
- }
2874
- function pluginsPayload(control) {
2875
- const installed = control.list();
2876
- const installedMap = new Map(
2877
- installed.map((p) => [
2878
- p.name,
2879
- { version: p.version, linked: p.linked, updateAllowed: p.updateAllowed }
2880
- ])
2881
- );
2882
- const shown = displayNamesOf(control);
2883
- return {
2884
- installed: installed.map((p) => toWirePluginEntry(p, shown)),
2885
- hits: control.search().map((hit) => toWirePluginHit(hit, installedMap)),
2886
- marketplaces: control.marketplaces().map(toWireMarketplace),
2887
- pendingRestart: control.pendingRestart,
2888
- uploadEnabled: control.uploadEnabled,
2889
- uploadOptions: control.uploadOptions,
2890
- refreshEnabled: control.refreshEnabled,
2891
- localInstallEnabled: control.localInstallEnabled
2892
- };
2893
- }
2894
- function sendPreview(res, outcome) {
3016
+ async function reconnectMcp(ctx, res, name, lang) {
3017
+ const outcome = await ctx.runtime.mcp.reconnect(name);
2895
3018
  if (!outcome.ok) {
3019
+ if (outcome.reason === "unknown") {
3020
+ return sendError(res, 404, "unknown-mcp-server", t("web.mcp_unknown_server", { name }, lang));
3021
+ }
3022
+ const status = {
3023
+ ...outcome.status,
3024
+ ...outcome.detail === void 0 ? {} : { lastError: outcome.detail }
3025
+ };
2896
3026
  return sendJson(res, 200, {
2897
3027
  ok: false,
2898
- preview: null,
2899
- token: null,
2900
- reason: outcome.reason,
2901
- detail: outcome.detail
3028
+ server: toWireMcp(status)
2902
3029
  });
2903
3030
  }
2904
3031
  sendJson(res, 200, {
2905
3032
  ok: true,
2906
- preview: toWirePreview2(outcome.preview),
2907
- token: outcome.token,
2908
- reason: null,
2909
- detail: null
3033
+ server: toWireMcp(outcome.status)
2910
3034
  });
2911
3035
  }
2912
- async function previewPluginInstall(ctx, req, res, lang) {
2913
- if (refusedByLan(ctx, res, lang)) return;
2914
- const control = controlOf(ctx, res, lang);
2915
- if (!control) return;
3036
+ async function addMcp(ctx, req, res, lang) {
3037
+ if (ctx.lanExposed) {
3038
+ return sendError(
3039
+ res,
3040
+ 403,
3041
+ "mcp-add-lan-exposed",
3042
+ t("web.mcp_add_lan_exposed", void 0, lang)
3043
+ );
3044
+ }
2916
3045
  const body = await readJsonBody(req);
2917
3046
  if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2918
- sendPreview(res, await control.preview(fieldOf(asRecord(body.value), "ref"), lang));
3047
+ const payload = asRecord(body.value);
3048
+ const name = typeof payload["name"] === "string" ? payload["name"].trim() : "";
3049
+ const transport = payload["transport"];
3050
+ if (transport !== "stdio" && transport !== "sse" && transport !== "http") {
3051
+ return sendError(res, 400, "bad-mcp-config", t("web.mcp_add_bad_transport", void 0, lang));
3052
+ }
3053
+ const outcome = await ctx.runtime.mcp.add({
3054
+ name,
3055
+ transport,
3056
+ ...typeof payload["command"] === "string" ? { command: payload["command"].trim() } : {},
3057
+ ...Array.isArray(payload["args"]) ? { args: stringList(payload["args"]) } : {},
3058
+ ...typeof payload["url"] === "string" ? { url: payload["url"].trim() } : {},
3059
+ ...payload["env"] === void 0 ? {} : { env: stringMap(payload["env"]) }
3060
+ });
3061
+ if (!outcome.ok) {
3062
+ return sendError(res, ...addFailure(outcome.reason, outcome.detail, name, lang));
3063
+ }
3064
+ sendJson(res, 200, {
3065
+ server: toWireMcp(outcome.status),
3066
+ configPath: outcome.configPath
3067
+ });
2919
3068
  }
2920
- async function previewLocalPluginInstall(ctx, req, res, lang) {
2921
- if (refusedByLan(ctx, res, lang)) return;
2922
- const control = controlOf(ctx, res, lang);
2923
- if (!control) return;
2924
- const body = await readJsonBody(req);
2925
- if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2926
- sendPreview(res, await control.previewLocal(fieldOf(asRecord(body.value), "path"), lang));
3069
+ function addFailure(reason, detail, name, lang) {
3070
+ if (reason === "invalid-name") {
3071
+ return [400, "bad-mcp-name", t("web.mcp_add_bad_name", { name }, lang)];
3072
+ }
3073
+ if (reason === "invalid-config") return [400, "bad-mcp-config", detail];
3074
+ if (reason === "duplicate")
3075
+ return [409, "mcp-name-taken", t("web.mcp_add_taken", { name }, lang)];
3076
+ return [409, "mcp-config-unwritable", t("web.mcp_add_unwritable", { detail }, lang)];
2927
3077
  }
2928
- async function previewPluginUpdate(ctx, req, res, lang) {
2929
- if (refusedByLan(ctx, res, lang)) return;
2930
- const control = controlOf(ctx, res, lang);
2931
- if (!control) return;
2932
- const body = await readJsonBody(req);
2933
- if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2934
- sendPreview(res, await control.previewUpdate(fieldOf(asRecord(body.value), "name"), lang));
3078
+ function stringList(value) {
3079
+ return value.filter((v) => typeof v === "string");
2935
3080
  }
2936
- function sendAction(res, control, outcome) {
2937
- if (!outcome.ok) {
2938
- return sendJson(res, 200, {
2939
- ok: false,
2940
- entry: null,
2941
- pendingRestart: control.pendingRestart,
2942
- detail: outcome.detail,
2943
- reason: outcome.reason
2944
- });
3081
+ function stringMap(value) {
3082
+ const out = {};
3083
+ for (const [k, v] of Object.entries(asRecord(value))) {
3084
+ if (typeof v === "string") out[k] = v;
2945
3085
  }
2946
- const fresh = "record" in outcome ? control.list().find((p) => p.name === outcome.record.name) : void 0;
3086
+ return out;
3087
+ }
3088
+ function toRef(raw) {
3089
+ return parseModelRef(raw);
3090
+ }
3091
+ function toWireSelection(selection) {
3092
+ return { provider: selection.provider, model: selection.model, origin: selection.origin };
3093
+ }
3094
+ function sessionHasImages(session) {
3095
+ const history = session?.getHistory() ?? [];
3096
+ return history.some((m) => m.parts?.some((p) => p.type === "image"));
3097
+ }
3098
+ function getModel(ctx, res, sessionId, _lang) {
3099
+ const { runtime } = ctx;
3100
+ const live = runtime.sessionFactory.get(sessionId);
3101
+ const selection = live?.model.get();
2947
3102
  sendJson(res, 200, {
2948
- ok: true,
2949
- entry: fresh ? toWirePluginEntry(fresh, displayNamesOf(control)) : null,
2950
- pendingRestart: control.pendingRestart,
2951
- detail: "detail" in outcome ? outcome.detail : null,
2952
- reason: null
3103
+ selection: selection ? toWireSelection(selection) : null,
3104
+ configured: (live ? live.model.configured() : runtime.config.model) || null
2953
3105
  });
2954
3106
  }
2955
- async function installPluginFromMarket(ctx, req, res, lang) {
2956
- if (refusedByLan(ctx, res, lang)) return;
2957
- const control = controlOf(ctx, res, lang);
2958
- if (!control) return;
3107
+ async function setModel(ctx, req, res, sessionId, lang) {
3108
+ const live = ctx.runtime.sessionFactory.get(sessionId);
3109
+ if (!live) return sendUnknownSession(res, sessionId);
3110
+ const control = live.model;
2959
3111
  const body = await readJsonBody(req);
2960
3112
  if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2961
- const payload = asRecord(body.value);
2962
- sendAction(
2963
- res,
2964
- control,
2965
- await control.install(fieldOf(payload, "ref"), fieldOf(payload, "token"), lang)
2966
- );
2967
- }
2968
- async function installLocalPlugin(ctx, req, res, lang) {
2969
- if (refusedByLan(ctx, res, lang)) return;
2970
- const control = controlOf(ctx, res, lang);
2971
- if (!control) return;
2972
- const body = await readJsonBody(req);
2973
- if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2974
- const payload = asRecord(body.value);
2975
- sendAction(
2976
- res,
2977
- control,
2978
- await control.installLocal(fieldOf(payload, "path"), fieldOf(payload, "token"), lang)
2979
- );
2980
- }
2981
- async function stagePlugin(ctx, req, res, lang) {
2982
- if (refusedByLan(ctx, res, lang)) return;
2983
- const control = controlOf(ctx, res, lang);
2984
- if (!control) return;
2985
- const asJson = (req.headers["content-type"] ?? "").toLowerCase().includes("application/json");
2986
- if (asJson) {
2987
- const body = await readJsonBody(req, void 0, lang);
2988
- if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2989
- return sendStage(
2990
- res,
2991
- await control.stage({ kind: "path", path: fieldOf(asRecord(body.value), "path") }, lang)
2992
- );
2993
- }
2994
- const raw = await readRawBody(req, PLUGIN_UPLOAD_BODY_LIMIT, lang);
2995
- if (!raw.ok) return sendError(res, raw.status, "bad-body", raw.message);
2996
- return sendStage(
2997
- res,
2998
- await control.stage({ kind: "bytes", name: nameParam2(req), data: raw.value }, lang)
2999
- );
3000
- }
3001
- function nameParam2(req) {
3002
- try {
3003
- return new URL(req.url ?? "/", "http://127.0.0.1").searchParams.get("name") ?? "";
3004
- } catch {
3005
- return "";
3113
+ const input = readModelSetBody(asRecord(body.value));
3114
+ if (!input.ok)
3115
+ return sendError(res, 400, "bad-model", t("web.model_bad_request", void 0, lang));
3116
+ const ref = input.model === null ? null : toRef(input.model);
3117
+ if (ref === void 0) {
3118
+ return sendError(res, 400, "bad-model", t("web.model_bad_request", void 0, lang));
3006
3119
  }
3120
+ const selectionCtx = {
3121
+ hasImages: sessionHasImages(live.session),
3122
+ ...input.usedTokens === void 0 ? {} : { usedTokens: input.usedTokens }
3123
+ };
3124
+ const result = ref === null ? control.reset(selectionCtx) : control.set(ref, selectionCtx);
3125
+ const payload = result.ok ? {
3126
+ ok: true,
3127
+ selection: toWireSelection(result.selection),
3128
+ contextLength: result.contextLength,
3129
+ caveats: result.caveats
3130
+ } : { ok: false, rejection: result.rejection };
3131
+ sendJson(res, 200, payload);
3007
3132
  }
3008
- function sendStage(res, outcome) {
3009
- const payload = outcome.ok ? {
3133
+ function readModelSetBody(body) {
3134
+ const { model, usedTokens } = body;
3135
+ if (model !== null && (typeof model !== "string" || model.trim() === "")) return { ok: false };
3136
+ const used = typeof usedTokens === "number" && Number.isFinite(usedTokens) && usedTokens >= 0 ? Math.floor(usedTokens) : void 0;
3137
+ return {
3010
3138
  ok: true,
3011
- path: outcome.path,
3012
- name: outcome.name,
3013
- version: outcome.version,
3014
- detail: null,
3015
- reason: null
3016
- } : {
3017
- ok: false,
3018
- path: null,
3019
- name: null,
3020
- version: null,
3021
- detail: outcome.detail,
3022
- reason: outcome.reason
3139
+ model: model === null ? null : model.trim(),
3140
+ ...used === void 0 ? {} : { usedTokens: used }
3023
3141
  };
3024
- sendJson(res, 200, payload);
3025
3142
  }
3026
- async function updateInstalledPlugin(ctx, req, res, lang) {
3027
- if (refusedByLan(ctx, res, lang)) return;
3028
- const control = controlOf(ctx, res, lang);
3029
- if (!control) return;
3030
- const body = await readJsonBody(req);
3031
- if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3032
- const payload = asRecord(body.value);
3033
- sendAction(
3034
- res,
3035
- control,
3036
- await control.update(fieldOf(payload, "name"), fieldOf(payload, "token"), lang)
3037
- );
3143
+ function planModeState(ctx, sessionId) {
3144
+ if (!ctx.runtime.plan) return null;
3145
+ const plan = ctx.runtime.sessionFactory.get(sessionId)?.plan;
3146
+ if (!plan) return { active: false, from: null };
3147
+ return { active: plan.active(), from: plan.from() };
3038
3148
  }
3039
- async function uninstallPluginByName(ctx, req, res, lang) {
3040
- if (refusedByLan(ctx, res, lang)) return;
3041
- const control = controlOf(ctx, res, lang);
3042
- if (!control) return;
3149
+ async function setPlanMode(ctx, req, res, sessionId, lang) {
3150
+ const live = ctx.runtime.sessionFactory.get(sessionId);
3151
+ if (!live) return sendUnknownSession(res, sessionId);
3152
+ const plan = live.plan;
3153
+ if (!plan) {
3154
+ return sendError(res, 503, "no-plan-mode", t("web.plan_mode_unavailable", void 0, lang));
3155
+ }
3043
3156
  const body = await readJsonBody(req);
3044
3157
  if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3045
- sendAction(res, control, await control.uninstall(fieldOf(asRecord(body.value), "name"), lang));
3046
- }
3047
- function sendMarket(res, outcome) {
3158
+ const action = asRecord(body.value)["action"];
3159
+ if (typeof action !== "string" || !isWirePlanAction(action)) {
3160
+ return sendError(
3161
+ res,
3162
+ 400,
3163
+ "bad-action",
3164
+ t("web.plan_mode_bad_action", { actions: WIRE_PLAN_ACTIONS.join("/") }, lang)
3165
+ );
3166
+ }
3167
+ const changed = action === "enter" ? plan.enter().ok : plan.leave() !== null;
3048
3168
  sendJson(res, 200, {
3049
- ok: outcome.ok,
3050
- marketplace: outcome.ok && outcome.marketplace ? toWireMarketplace(outcome.marketplace) : null,
3051
- reason: outcome.ok ? null : outcome.reason,
3052
- detail: outcome.ok ? null : outcome.detail
3169
+ mode: { active: plan.active(), from: plan.from() },
3170
+ changed
3053
3171
  });
3054
3172
  }
3055
- async function addPluginMarketplace(ctx, req, res, lang) {
3056
- if (refusedByLan(ctx, res, lang)) return;
3057
- const control = controlOf(ctx, res, lang);
3058
- if (!control) return;
3059
- const body = await readJsonBody(req);
3060
- if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3061
- sendMarket(res, await control.addMarketplace(fieldOf(asRecord(body.value), "source"), lang));
3173
+ // src/permission.ts
3174
+ var REFUSAL = {
3175
+ "managed-bypass-disabled": "managed"
3176
+ };
3177
+ function blockedLevels(managed) {
3178
+ return managed.bypassDisabled ? [{ level: "bypass", reason: "managed" }] : [];
3062
3179
  }
3063
- async function removePluginMarketplace(ctx, req, res, lang) {
3064
- if (refusedByLan(ctx, res, lang)) return;
3065
- const control = controlOf(ctx, res, lang);
3066
- if (!control) return;
3067
- const body = await readJsonBody(req);
3068
- if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3069
- sendMarket(res, await control.removeMarketplace(fieldOf(asRecord(body.value), "name"), lang));
3180
+ function permissionState(ctx, sessionId) {
3181
+ const perms = ctx.runtime.sessionFactory.get(sessionId)?.permissions ?? null;
3182
+ return {
3183
+ level: perms?.level() ?? ctx.runtime.config.permission,
3184
+ editable: perms !== null,
3185
+ blocked: perms ? blockedLevels(perms.managed()) : [],
3186
+ planMode: planModeState(ctx, sessionId)
3187
+ };
3070
3188
  }
3071
- function previewPluginUpload(ctx, req, res, lang) {
3072
- return withUploadBody(ctx, req, res, lang, (control, payload) => {
3073
- sendUploadPreview(res, control.previewUpload(fieldOf(payload, "path"), lang));
3074
- });
3189
+ function getPermission(ctx, res, sessionId, _lang) {
3190
+ if (!ctx.hub.has(sessionId)) return sendUnknownSession(res, sessionId);
3191
+ sendJson(res, 200, permissionState(ctx, sessionId));
3075
3192
  }
3076
- function uploadPlugin(ctx, req, res, lang) {
3077
- return withUploadBody(ctx, req, res, lang, async (control, payload) => {
3078
- sendUpload(
3193
+ async function setPermission(ctx, req, res, sessionId, lang) {
3194
+ const live = ctx.runtime.sessionFactory.get(sessionId);
3195
+ if (!live) return sendUnknownSession(res, sessionId);
3196
+ const perms = live.permissions;
3197
+ if (!perms) {
3198
+ return sendError(
3079
3199
  res,
3080
- await control.upload(
3081
- fieldOf(payload, "path"),
3082
- fieldOf(payload, "token"),
3083
- uploadFormOf(payload),
3084
- lang
3085
- )
3200
+ 503,
3201
+ "no-permission-layer",
3202
+ t("web.permission_unavailable", void 0, lang)
3086
3203
  );
3087
- });
3088
- }
3089
- async function withUploadBody(ctx, req, res, lang, run) {
3090
- if (refusedByLan(ctx, res, lang)) return;
3091
- const control = controlOf(ctx, res, lang);
3092
- if (!control) return;
3204
+ }
3093
3205
  const body = await readJsonBody(req);
3094
3206
  if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3095
- await run(control, asRecord(body.value));
3096
- }
3097
- function sendUploadPreview(res, outcome) {
3098
- if (!outcome.ok) {
3099
- return sendJson(res, 200, {
3100
- ok: false,
3101
- plan: null,
3102
- token: null,
3103
- prefill: null,
3104
- fromManifest: null,
3105
- reason: outcome.reason,
3106
- detail: outcome.detail
3107
- });
3207
+ const level = asRecord(body.value)["level"];
3208
+ if (typeof level !== "string" || !isPermissionLevel(level)) {
3209
+ return sendError(
3210
+ res,
3211
+ 400,
3212
+ "bad-level",
3213
+ t("web.permission_bad_level", { levels: PERMISSION_LEVELS.join("/") }, lang)
3214
+ );
3108
3215
  }
3109
- const { manifest, files, totalBytes, suspicious, skippedLinks } = outcome.plan;
3110
- sendJson(res, 200, {
3216
+ const before = perms.level();
3217
+ const result = perms.setLevel(level, { remember: true });
3218
+ const state = permissionState(ctx, sessionId);
3219
+ const payload = result.ok ? {
3111
3220
  ok: true,
3112
- plan: {
3113
- name: manifest.name,
3114
- version: manifest.version,
3115
- description: manifest.description ?? "",
3116
- author: manifest.author?.name ?? null,
3117
- files: files.map((f) => ({ path: f.path, bytes: f.bytes })),
3118
- totalBytes,
3119
- suspicious: [...suspicious],
3120
- skippedLinks: [...skippedLinks]
3121
- },
3122
- token: outcome.token,
3123
- prefill: outcome.prefill,
3124
- fromManifest: [...outcome.fromManifest],
3125
- reason: null,
3126
- detail: null
3127
- });
3221
+ changed: state.level !== before,
3222
+ state,
3223
+ ...result.notRemembered ? { notRemembered: result.notRemembered } : {}
3224
+ } : { ok: false, refused: REFUSAL[result.reason], changed: false, state };
3225
+ sendJson(res, 200, payload);
3128
3226
  }
3129
- function sendUpload(res, outcome) {
3227
+ // src/plan.ts
3228
+ function getPlan(ctx, res, sessionId, _lang) {
3229
+ if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
3230
+ return sendUnknownSession(res, sessionId);
3231
+ }
3232
+ const live = ctx.runtime.sessionFactory.get(sessionId);
3233
+ const markdown = live ? live.plan?.approvedPlan() ?? null : ctx.runtime.sessionStore?.loadPlan(sessionId) ?? null;
3130
3234
  sendJson(res, 200, {
3131
- ok: outcome.ok,
3132
- detail: outcome.detail,
3133
- uploaded: outcome.ok ? { name: outcome.name, version: outcome.version, bytes: outcome.bytes } : null,
3134
- reason: outcome.ok ? null : outcome.reason
3235
+ plan: normalize(markdown),
3236
+ mode: planModeState(ctx, sessionId)
3135
3237
  });
3136
3238
  }
3137
- async function refreshPlugins(ctx, res, lang) {
3138
- if (refusedByLan(ctx, res, lang)) return;
3139
- const control = controlOf(ctx, res, lang);
3140
- if (!control) return;
3141
- const outcome = await control.refresh(lang);
3142
- if (!outcome.ok) {
3143
- return sendJson(res, 200, {
3144
- ok: false,
3145
- plugins: null,
3146
- reason: outcome.reason,
3147
- detail: outcome.detail
3148
- });
3149
- }
3150
- sendJson(res, 200, {
3151
- ok: true,
3152
- plugins: pluginsPayload(control),
3153
- reason: null,
3154
- detail: outcome.detail === "" ? null : outcome.detail
3155
- });
3239
+ function normalize(markdown) {
3240
+ return markdown?.trim() ? markdown : null;
3156
3241
  }
3157
- function toWireProvider(option, lang) {
3242
+ function toWireCounts(counts) {
3158
3243
  return {
3159
- type: option.type,
3160
- label: providerLabel(option, lang),
3161
- envVar: option.envVar,
3162
- hasKey: option.hasKey,
3163
- keyHint: option.keyHint,
3164
- current: option.current
3244
+ commands: counts.commands,
3245
+ roles: counts.roles,
3246
+ skills: counts.skills,
3247
+ hooks: counts.hooks,
3248
+ denyRules: counts.denyRules,
3249
+ mcpServers: counts.mcpServers
3165
3250
  };
3166
3251
  }
3167
- function toWireSuggestions(view) {
3252
+ function displayNamesOf(control) {
3253
+ const out = /* @__PURE__ */ new Map();
3254
+ for (const hit of control.search()) {
3255
+ const shown = hit.entry.displayName;
3256
+ if (shown !== void 0 && shown !== "") out.set(`${hit.marketplace}/${hit.entry.name}`, shown);
3257
+ }
3258
+ return out;
3259
+ }
3260
+ function toWirePluginEntry(entry, displayNames) {
3168
3261
  return {
3169
- provider: view.provider,
3170
- models: view.models,
3171
- source: view.source,
3172
- status: view.status
3262
+ name: entry.name,
3263
+ displayName: displayNames.get(`${entry.marketplace}/${entry.name}`) ?? null,
3264
+ version: entry.version,
3265
+ source: entry.source,
3266
+ sourceType: entry.sourceType,
3267
+ path: entry.path,
3268
+ linked: entry.linked,
3269
+ enabled: entry.enabled,
3270
+ installedAt: entry.installedAt,
3271
+ marketplace: entry.marketplace ?? null,
3272
+ active: entry.active,
3273
+ counts: entry.counts ? toWireCounts(entry.counts) : null,
3274
+ updateAllowed: entry.updateAllowed,
3275
+ uninstallAllowed: entry.uninstallAllowed
3173
3276
  };
3174
3277
  }
3175
- function listProviders(ctx, res, lang) {
3278
+ function toWirePluginHit(hit, installed) {
3279
+ const mine = installed.get(hit.entry.name);
3280
+ return {
3281
+ marketplace: hit.marketplace,
3282
+ ref: hit.ref,
3283
+ name: hit.entry.name,
3284
+ description: hit.entry.description ?? "",
3285
+ categories: hit.entry.category ?? null,
3286
+ keywords: [...hit.entry.keywords ?? []],
3287
+ sourceType: hit.sourceType ?? null,
3288
+ installable: hit.installable,
3289
+ installed: mine !== void 0,
3290
+ author: hit.entry.author?.name ?? null,
3291
+ downloads: hit.entry.downloads ?? null,
3292
+ likes: hit.entry.likes ?? null,
3293
+ displayName: hit.entry.displayName ?? null,
3294
+ icon: hit.entry.icon ?? null,
3295
+ version: hit.entry.version ?? null,
3296
+ changelog: hit.entry.changelog ?? null,
3297
+ size: hit.entry.size ?? null,
3298
+ homepage: hit.entry.homepage ?? null,
3299
+ visibility: hit.entry.visibility ?? null,
3300
+ status: hit.entry.status ?? null,
3301
+ updateAvailable: mine !== void 0 && !mine.linked && hit.entry.version !== void 0 && hit.entry.version !== mine.version,
3302
+ installAllowed: hit.installAllowed,
3303
+ updateAllowed: mine?.updateAllowed ?? false,
3304
+ menu: hit.menu.map((item) => ({
3305
+ id: item.id,
3306
+ label: item.label,
3307
+ danger: item.danger === true
3308
+ }))
3309
+ };
3310
+ }
3311
+ function toWireMarketplace(market) {
3312
+ return {
3313
+ name: market.name,
3314
+ source: market.source,
3315
+ entries: market.entries,
3316
+ addedAt: market.addedAt,
3317
+ local: market.local,
3318
+ categories: (market.categories ?? []).map(toWireCategory)
3319
+ };
3320
+ }
3321
+ function toWireCategory(node) {
3322
+ return {
3323
+ id: node.id,
3324
+ label: node.label ?? node.id,
3325
+ children: (node.children ?? []).map(toWireCategory)
3326
+ };
3327
+ }
3328
+ function toWirePreview2(preview) {
3329
+ const { manifest, source, inventory } = preview;
3330
+ return {
3331
+ name: manifest.name,
3332
+ version: manifest.version,
3333
+ description: manifest.description ?? "",
3334
+ source: source.raw,
3335
+ sourceType: source.type,
3336
+ checksum: source.sha256 ?? null,
3337
+ author: manifest.author?.name ?? null,
3338
+ homepage: manifest.homepage ?? null,
3339
+ commands: [...inventory.commands],
3340
+ roles: [...inventory.roles],
3341
+ skills: [...inventory.skills],
3342
+ hooks: inventory.hooks.map((h) => ({ type: h.type, count: h.count })),
3343
+ denyRules: inventory.denyRules,
3344
+ mcpServers: [...inventory.mcpServers],
3345
+ ignoredBuckets: [...inventory.ignoredBuckets],
3346
+ hasJsTools: inventory.jsTools,
3347
+ conflict: preview.conflict ? { name: preview.conflict.name, version: preview.conflict.version } : null
3348
+ };
3349
+ }
3350
+ function controlOf(ctx, res, lang) {
3351
+ const control = ctx.runtime.plugins;
3352
+ if (!control) {
3353
+ sendError(res, 503, "no-plugin-control", t("web.plugin_not_available", void 0, lang));
3354
+ return null;
3355
+ }
3356
+ return control;
3357
+ }
3358
+ function refusedByLan(ctx, res, lang) {
3359
+ if (!ctx.lanExposed) return false;
3360
+ sendError(
3361
+ res,
3362
+ 403,
3363
+ "plugin-write-lan-exposed",
3364
+ t("web.plugin_write_lan_exposed", void 0, lang)
3365
+ );
3366
+ return true;
3367
+ }
3368
+ function fieldOf(payload, key) {
3369
+ const value = payload[key];
3370
+ return typeof value === "string" ? value.trim() : "";
3371
+ }
3372
+ function uploadFormOf(payload) {
3373
+ const form = {};
3374
+ for (const name of WIRE_PLUGIN_UPLOAD_FORM_FIELDS) {
3375
+ const raw = fieldOf(payload, name);
3376
+ if (raw === "") continue;
3377
+ if (name === "visibility") {
3378
+ if (raw === "private" || raw === "public") form[name] = raw;
3379
+ continue;
3380
+ }
3381
+ form[name] = raw;
3382
+ }
3383
+ return form;
3384
+ }
3385
+ function listPlugins(ctx, res, lang) {
3386
+ const control = controlOf(ctx, res, lang);
3387
+ if (!control) return;
3388
+ sendJson(res, 200, pluginsPayload(control));
3389
+ }
3390
+ function pluginsPayload(control) {
3391
+ const installed = control.list();
3392
+ const installedMap = new Map(
3393
+ installed.map((p) => [
3394
+ p.name,
3395
+ { version: p.version, linked: p.linked, updateAllowed: p.updateAllowed }
3396
+ ])
3397
+ );
3398
+ const shown = displayNamesOf(control);
3399
+ return {
3400
+ installed: installed.map((p) => toWirePluginEntry(p, shown)),
3401
+ hits: control.search().map((hit) => toWirePluginHit(hit, installedMap)),
3402
+ marketplaces: control.marketplaces().map(toWireMarketplace),
3403
+ pendingRestart: control.pendingRestart,
3404
+ uploadEnabled: control.uploadEnabled,
3405
+ uploadOptions: control.uploadOptions,
3406
+ refreshEnabled: control.refreshEnabled,
3407
+ localInstallEnabled: control.localInstallEnabled
3408
+ };
3409
+ }
3410
+ function sendPreview(res, outcome) {
3411
+ if (!outcome.ok) {
3412
+ return sendJson(res, 200, {
3413
+ ok: false,
3414
+ preview: null,
3415
+ token: null,
3416
+ reason: outcome.reason,
3417
+ detail: outcome.detail
3418
+ });
3419
+ }
3176
3420
  sendJson(res, 200, {
3177
- providers: ctx.runtime.modelCatalog.providers().map((o) => toWireProvider(o, lang))
3421
+ ok: true,
3422
+ preview: toWirePreview2(outcome.preview),
3423
+ token: outcome.token,
3424
+ reason: null,
3425
+ detail: null
3178
3426
  });
3179
3427
  }
3180
- async function discoverProviderModels(ctx, req, res, type, lang) {
3181
- if (!isProviderType(type)) {
3182
- return sendError(res, 404, "unknown-provider", t("web.provider_unknown", { name: type }, lang));
3183
- }
3428
+ async function previewPluginInstall(ctx, req, res, lang) {
3429
+ if (refusedByLan(ctx, res, lang)) return;
3430
+ const control = controlOf(ctx, res, lang);
3431
+ if (!control) return;
3184
3432
  const body = await readJsonBody(req);
3185
3433
  if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3186
- const refresh = asRecord(body.value).refresh === true;
3187
- const suggestions = await ctx.runtime.modelCatalog.discover(type, { refresh });
3188
- sendJson(res, 200, {
3189
- suggestions: toWireSuggestions(suggestions)
3190
- });
3434
+ sendPreview(res, await control.preview(fieldOf(asRecord(body.value), "ref"), lang));
3191
3435
  }
3192
- async function setProviderKey(ctx, req, res, type, lang) {
3193
- if (!isProviderType(type)) {
3194
- return sendError(res, 404, "unknown-provider", t("web.provider_unknown", { name: type }, lang));
3195
- }
3196
- const envVar = apiKeyEnvVar(type);
3197
- if (envVar === null) {
3198
- return sendError(
3199
- res,
3200
- 400,
3201
- "key-not-applicable",
3202
- t("web.provider_key_not_applicable", { name: type }, lang)
3203
- );
3204
- }
3436
+ async function previewLocalPluginInstall(ctx, req, res, lang) {
3437
+ if (refusedByLan(ctx, res, lang)) return;
3438
+ const control = controlOf(ctx, res, lang);
3439
+ if (!control) return;
3205
3440
  const body = await readJsonBody(req);
3206
3441
  if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3207
- const raw = asRecord(body.value)["apiKey"];
3208
- const apiKey = typeof raw === "string" ? raw.trim() : "";
3209
- if (apiKey === "") {
3210
- return sendError(res, 400, "empty-key", t("web.provider_key_empty", void 0, lang));
3442
+ sendPreview(res, await control.previewLocal(fieldOf(asRecord(body.value), "path"), lang));
3443
+ }
3444
+ async function previewPluginUpdate(ctx, req, res, lang) {
3445
+ if (refusedByLan(ctx, res, lang)) return;
3446
+ const control = controlOf(ctx, res, lang);
3447
+ if (!control) return;
3448
+ const body = await readJsonBody(req);
3449
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3450
+ sendPreview(res, await control.previewUpdate(fieldOf(asRecord(body.value), "name"), lang));
3451
+ }
3452
+ function sendAction(res, control, outcome) {
3453
+ if (!outcome.ok) {
3454
+ return sendJson(res, 200, {
3455
+ ok: false,
3456
+ entry: null,
3457
+ pendingRestart: control.pendingRestart,
3458
+ detail: outcome.detail,
3459
+ reason: outcome.reason
3460
+ });
3211
3461
  }
3212
- const written = await ctx.runtime.modelCatalog.setKey(type, apiKey);
3462
+ const fresh = "record" in outcome ? control.list().find((p) => p.name === outcome.record.name) : void 0;
3213
3463
  sendJson(res, 200, {
3214
- provider: type,
3215
- hasKey: true,
3216
- keyHint: written.keyHint,
3217
- backend: written.backend,
3218
- encrypted: written.encrypted,
3219
- envPath: written.envPath
3464
+ ok: true,
3465
+ entry: fresh ? toWirePluginEntry(fresh, displayNamesOf(control)) : null,
3466
+ pendingRestart: control.pendingRestart,
3467
+ detail: "detail" in outcome ? outcome.detail : null,
3468
+ reason: null
3220
3469
  });
3221
3470
  }
3222
- async function setDefaultModel(ctx, req, res, lang) {
3223
- if (ctx.runtime.providerInfo !== null) {
3224
- return sendError(
3225
- res,
3226
- 409,
3227
- "provider-available",
3228
- t("web.model_default_has_provider", void 0, lang)
3229
- );
3230
- }
3231
- const body = await readJsonBody(req, void 0, lang);
3471
+ async function installPluginFromMarket(ctx, req, res, lang) {
3472
+ if (refusedByLan(ctx, res, lang)) return;
3473
+ const control = controlOf(ctx, res, lang);
3474
+ if (!control) return;
3475
+ const body = await readJsonBody(req);
3232
3476
  if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3233
- const raw = asRecord(body.value)["model"];
3234
- const model = typeof raw === "string" ? raw.trim() : "";
3235
- if (model === "") {
3236
- return sendError(res, 400, "bad-request", t("web.model_default_empty", void 0, lang));
3237
- }
3238
- const report = ctx.runtime.settings.write({ key: "model", layer: "user", value: model });
3239
- if (!report.ok) {
3240
- const failure = settingWriteFailure(report);
3241
- return sendError(
3477
+ const payload = asRecord(body.value);
3478
+ sendAction(
3479
+ res,
3480
+ control,
3481
+ await control.install(fieldOf(payload, "ref"), fieldOf(payload, "token"), lang)
3482
+ );
3483
+ }
3484
+ async function installLocalPlugin(ctx, req, res, lang) {
3485
+ if (refusedByLan(ctx, res, lang)) return;
3486
+ const control = controlOf(ctx, res, lang);
3487
+ if (!control) return;
3488
+ const body = await readJsonBody(req);
3489
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3490
+ const payload = asRecord(body.value);
3491
+ sendAction(
3492
+ res,
3493
+ control,
3494
+ await control.installLocal(fieldOf(payload, "path"), fieldOf(payload, "token"), lang)
3495
+ );
3496
+ }
3497
+ async function stagePlugin(ctx, req, res, lang) {
3498
+ if (refusedByLan(ctx, res, lang)) return;
3499
+ const control = controlOf(ctx, res, lang);
3500
+ if (!control) return;
3501
+ const asJson = (req.headers["content-type"] ?? "").toLowerCase().includes("application/json");
3502
+ if (asJson) {
3503
+ const body = await readJsonBody(req, void 0, lang);
3504
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3505
+ return sendStage(
3242
3506
  res,
3243
- failure.status,
3244
- failure.code,
3245
- t(failure.key, { path: failure.path }, lang)
3507
+ await control.stage({ kind: "path", path: fieldOf(asRecord(body.value), "path") }, lang)
3246
3508
  );
3247
3509
  }
3248
- sendJson(res, 200, toWireSettingsWrite(report));
3510
+ const raw = await readRawBody(req, PLUGIN_UPLOAD_BODY_LIMIT, lang);
3511
+ if (!raw.ok) return sendError(res, raw.status, "bad-body", raw.message);
3512
+ return sendStage(
3513
+ res,
3514
+ await control.stage({ kind: "bytes", name: nameParam2(req), data: raw.value }, lang)
3515
+ );
3249
3516
  }
3250
- async function addRole(ctx, req, res, lang) {
3251
- if (ctx.lanExposed) {
3252
- return sendError(
3253
- res,
3254
- 403,
3255
- "role-add-lan-exposed",
3256
- t("web.role_add_lan_exposed", void 0, lang)
3257
- );
3517
+ function nameParam2(req) {
3518
+ try {
3519
+ return new URL(req.url ?? "/", "http://127.0.0.1").searchParams.get("name") ?? "";
3520
+ } catch {
3521
+ return "";
3258
3522
  }
3523
+ }
3524
+ function sendStage(res, outcome) {
3525
+ const payload = outcome.ok ? {
3526
+ ok: true,
3527
+ path: outcome.path,
3528
+ name: outcome.name,
3529
+ version: outcome.version,
3530
+ detail: null,
3531
+ reason: null
3532
+ } : {
3533
+ ok: false,
3534
+ path: null,
3535
+ name: null,
3536
+ version: null,
3537
+ detail: outcome.detail,
3538
+ reason: outcome.reason
3539
+ };
3540
+ sendJson(res, 200, payload);
3541
+ }
3542
+ async function updateInstalledPlugin(ctx, req, res, lang) {
3543
+ if (refusedByLan(ctx, res, lang)) return;
3544
+ const control = controlOf(ctx, res, lang);
3545
+ if (!control) return;
3259
3546
  const body = await readJsonBody(req);
3260
3547
  if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3261
3548
  const payload = asRecord(body.value);
3262
- const outcome = ctx.runtime.roleWrite.add(
3263
- {
3264
- name: typeof payload["name"] === "string" ? payload["name"].trim() : "",
3265
- description: typeof payload["description"] === "string" ? payload["description"].trim() : "",
3266
- ...typeof payload["prompt"] === "string" ? { prompt: payload["prompt"] } : {},
3267
- ...Array.isArray(payload["tools"]) ? { tools: stringList2(payload["tools"]) } : {},
3268
- ...Array.isArray(payload["skills"]) ? { skills: stringList2(payload["skills"]) } : {},
3269
- ...typeof payload["maxTurns"] === "number" ? { maxTurns: payload["maxTurns"] } : {}
3270
- },
3271
- lang
3549
+ sendAction(
3550
+ res,
3551
+ control,
3552
+ await control.update(fieldOf(payload, "name"), fieldOf(payload, "token"), lang)
3272
3553
  );
3273
- if (!outcome.ok) return sendError(res, ...addFailure2(outcome.reason, outcome.detail, lang));
3554
+ }
3555
+ async function uninstallPluginByName(ctx, req, res, lang) {
3556
+ if (refusedByLan(ctx, res, lang)) return;
3557
+ const control = controlOf(ctx, res, lang);
3558
+ if (!control) return;
3559
+ const body = await readJsonBody(req);
3560
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3561
+ sendAction(res, control, await control.uninstall(fieldOf(asRecord(body.value), "name"), lang));
3562
+ }
3563
+ function sendMarket(res, outcome) {
3274
3564
  sendJson(res, 200, {
3275
- role: toWireRole(outcome.role, ctx.runtime.agentRoleNotices),
3276
- path: outcome.path
3565
+ ok: outcome.ok,
3566
+ marketplace: outcome.ok && outcome.marketplace ? toWireMarketplace(outcome.marketplace) : null,
3567
+ reason: outcome.ok ? null : outcome.reason,
3568
+ detail: outcome.ok ? null : outcome.detail
3277
3569
  });
3278
3570
  }
3279
- function addFailure2(reason, detail, lang) {
3280
- if (reason === "invalid-name") return [400, "bad-role-name", detail];
3281
- if (reason === "invalid-field")
3282
- return [400, "bad-role-field", t("web.role_add_bad_field", { detail }, lang)];
3283
- if (reason === "unknown-tool") return [400, "bad-role-tools", detail];
3284
- if (reason === "duplicate") return [409, "role-name-taken", detail];
3285
- return [409, "role-unwritable", detail];
3571
+ async function addPluginMarketplace(ctx, req, res, lang) {
3572
+ if (refusedByLan(ctx, res, lang)) return;
3573
+ const control = controlOf(ctx, res, lang);
3574
+ if (!control) return;
3575
+ const body = await readJsonBody(req);
3576
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3577
+ sendMarket(res, await control.addMarketplace(fieldOf(asRecord(body.value), "source"), lang));
3286
3578
  }
3287
- function stringList2(value) {
3288
- return value.filter((v) => typeof v === "string");
3579
+ async function removePluginMarketplace(ctx, req, res, lang) {
3580
+ if (refusedByLan(ctx, res, lang)) return;
3581
+ const control = controlOf(ctx, res, lang);
3582
+ if (!control) return;
3583
+ const body = await readJsonBody(req);
3584
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3585
+ sendMarket(res, await control.removeMarketplace(fieldOf(asRecord(body.value), "name"), lang));
3289
3586
  }
3290
- async function removeRole(ctx, req, res, lang) {
3291
- if (ctx.lanExposed) {
3292
- return sendError(
3587
+ function previewPluginUpload(ctx, req, res, lang) {
3588
+ return withUploadBody(ctx, req, res, lang, (control, payload) => {
3589
+ sendUploadPreview(res, control.previewUpload(fieldOf(payload, "path"), lang));
3590
+ });
3591
+ }
3592
+ function uploadPlugin(ctx, req, res, lang) {
3593
+ return withUploadBody(ctx, req, res, lang, async (control, payload) => {
3594
+ sendUpload(
3293
3595
  res,
3294
- 403,
3295
- "role-remove-lan-exposed",
3296
- t("web.role_remove_lan_exposed", void 0, lang)
3596
+ await control.upload(
3597
+ fieldOf(payload, "path"),
3598
+ fieldOf(payload, "token"),
3599
+ uploadFormOf(payload),
3600
+ lang
3601
+ )
3297
3602
  );
3298
- }
3603
+ });
3604
+ }
3605
+ async function withUploadBody(ctx, req, res, lang, run) {
3606
+ if (refusedByLan(ctx, res, lang)) return;
3607
+ const control = controlOf(ctx, res, lang);
3608
+ if (!control) return;
3299
3609
  const body = await readJsonBody(req);
3300
3610
  if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3301
- const payload = asRecord(body.value);
3302
- const name = typeof payload["name"] === "string" ? payload["name"].trim() : "";
3303
- const outcome = ctx.runtime.roleWrite.remove(name, lang);
3611
+ await run(control, asRecord(body.value));
3612
+ }
3613
+ function sendUploadPreview(res, outcome) {
3304
3614
  if (!outcome.ok) {
3305
3615
  return sendJson(res, 200, {
3306
3616
  ok: false,
3307
- name: "",
3308
- path: null,
3309
- takenOverBy: null,
3310
- detail: outcome.detail,
3311
- reason: outcome.reason
3617
+ plan: null,
3618
+ token: null,
3619
+ prefill: null,
3620
+ fromManifest: null,
3621
+ reason: outcome.reason,
3622
+ detail: outcome.detail
3312
3623
  });
3313
3624
  }
3625
+ const { manifest, files, totalBytes, suspicious, skippedLinks } = outcome.plan;
3314
3626
  sendJson(res, 200, {
3315
3627
  ok: true,
3316
- name: outcome.name,
3317
- path: outcome.path,
3318
- takenOverBy: outcome.takenOverBy,
3319
- detail: null,
3320
- reason: null
3628
+ plan: {
3629
+ name: manifest.name,
3630
+ version: manifest.version,
3631
+ description: manifest.description ?? "",
3632
+ author: manifest.author?.name ?? null,
3633
+ files: files.map((f) => ({ path: f.path, bytes: f.bytes })),
3634
+ totalBytes,
3635
+ suspicious: [...suspicious],
3636
+ skippedLinks: [...skippedLinks]
3637
+ },
3638
+ token: outcome.token,
3639
+ prefill: outcome.prefill,
3640
+ fromManifest: [...outcome.fromManifest],
3641
+ reason: null,
3642
+ detail: null
3321
3643
  });
3322
3644
  }
3323
- function stringList3(value) {
3324
- if (!Array.isArray(value)) return null;
3325
- const out = [];
3326
- for (const item of value) {
3327
- if (typeof item !== "string" || item.trim().length === 0) return null;
3328
- out.push(item);
3329
- }
3330
- return out;
3331
- }
3332
- function operationList(value) {
3333
- if (!Array.isArray(value)) return null;
3334
- const out = [];
3335
- for (const item of value) {
3336
- if (typeof item !== "string" || !isOperationType(item)) return null;
3337
- out.push(item);
3338
- }
3339
- return out;
3340
- }
3341
- function finite(value) {
3342
- return typeof value === "number" && Number.isFinite(value) ? value : null;
3645
+ function sendUpload(res, outcome) {
3646
+ sendJson(res, 200, {
3647
+ ok: outcome.ok,
3648
+ detail: outcome.detail,
3649
+ uploaded: outcome.ok ? { name: outcome.name, version: outcome.version, bytes: outcome.bytes } : null,
3650
+ reason: outcome.ok ? null : outcome.reason
3651
+ });
3343
3652
  }
3344
- function parseTrigger(value) {
3653
+ async function refreshPlugins(ctx, res, lang) {
3654
+ if (refusedByLan(ctx, res, lang)) return;
3655
+ const control = controlOf(ctx, res, lang);
3656
+ if (!control) return;
3657
+ const outcome = await control.refresh(lang);
3658
+ if (!outcome.ok) {
3659
+ return sendJson(res, 200, {
3660
+ ok: false,
3661
+ plugins: null,
3662
+ reason: outcome.reason,
3663
+ detail: outcome.detail
3664
+ });
3665
+ }
3666
+ sendJson(res, 200, {
3667
+ ok: true,
3668
+ plugins: pluginsPayload(control),
3669
+ reason: null,
3670
+ detail: outcome.detail === "" ? null : outcome.detail
3671
+ });
3672
+ }
3673
+ function toWireProvider(option, lang) {
3674
+ return {
3675
+ type: option.type,
3676
+ label: providerLabel(option, lang),
3677
+ envVar: option.envVar,
3678
+ hasKey: option.hasKey,
3679
+ keyHint: option.keyHint,
3680
+ current: option.current
3681
+ };
3682
+ }
3683
+ function toWireSuggestions(view) {
3684
+ return {
3685
+ provider: view.provider,
3686
+ models: view.models,
3687
+ source: view.source,
3688
+ status: view.status
3689
+ };
3690
+ }
3691
+ function listProviders(ctx, res, lang) {
3692
+ sendJson(res, 200, {
3693
+ providers: ctx.runtime.modelCatalog.providers().map((o) => toWireProvider(o, lang))
3694
+ });
3695
+ }
3696
+ async function discoverProviderModels(ctx, req, res, type, lang) {
3697
+ if (!isProviderType(type)) {
3698
+ return sendError(res, 404, "unknown-provider", t("web.provider_unknown", { name: type }, lang));
3699
+ }
3700
+ const body = await readJsonBody(req);
3701
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3702
+ const refresh = asRecord(body.value).refresh === true;
3703
+ const suggestions = await ctx.runtime.modelCatalog.discover(type, { refresh });
3704
+ sendJson(res, 200, {
3705
+ suggestions: toWireSuggestions(suggestions)
3706
+ });
3707
+ }
3708
+ async function setProviderKey(ctx, req, res, type, lang) {
3709
+ if (!isProviderType(type)) {
3710
+ return sendError(res, 404, "unknown-provider", t("web.provider_unknown", { name: type }, lang));
3711
+ }
3712
+ const envVar = apiKeyEnvVar(type);
3713
+ if (envVar === null) {
3714
+ return sendError(
3715
+ res,
3716
+ 400,
3717
+ "key-not-applicable",
3718
+ t("web.provider_key_not_applicable", { name: type }, lang)
3719
+ );
3720
+ }
3721
+ const body = await readJsonBody(req);
3722
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3723
+ const raw = asRecord(body.value)["apiKey"];
3724
+ const apiKey = typeof raw === "string" ? raw.trim() : "";
3725
+ if (apiKey === "") {
3726
+ return sendError(res, 400, "empty-key", t("web.provider_key_empty", void 0, lang));
3727
+ }
3728
+ const written = await ctx.runtime.modelCatalog.setKey(type, apiKey);
3729
+ sendJson(res, 200, {
3730
+ provider: type,
3731
+ hasKey: true,
3732
+ keyHint: written.keyHint,
3733
+ backend: written.backend,
3734
+ encrypted: written.encrypted,
3735
+ envPath: written.envPath
3736
+ });
3737
+ }
3738
+ async function setDefaultModel(ctx, req, res, lang) {
3739
+ if (ctx.runtime.providerInfo !== null) {
3740
+ return sendError(
3741
+ res,
3742
+ 409,
3743
+ "provider-available",
3744
+ t("web.model_default_has_provider", void 0, lang)
3745
+ );
3746
+ }
3747
+ const body = await readJsonBody(req, void 0, lang);
3748
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3749
+ const raw = asRecord(body.value)["model"];
3750
+ const model = typeof raw === "string" ? raw.trim() : "";
3751
+ if (model === "") {
3752
+ return sendError(res, 400, "bad-request", t("web.model_default_empty", void 0, lang));
3753
+ }
3754
+ const report = ctx.runtime.settings.write({ key: "model", layer: "user", value: model });
3755
+ if (!report.ok) {
3756
+ const failure = settingWriteFailure(report);
3757
+ return sendError(
3758
+ res,
3759
+ failure.status,
3760
+ failure.code,
3761
+ t(failure.key, { path: failure.path }, lang)
3762
+ );
3763
+ }
3764
+ sendJson(res, 200, toWireSettingsWrite(report));
3765
+ }
3766
+ async function addRole(ctx, req, res, lang) {
3767
+ if (ctx.lanExposed) {
3768
+ return sendError(
3769
+ res,
3770
+ 403,
3771
+ "role-add-lan-exposed",
3772
+ t("web.role_add_lan_exposed", void 0, lang)
3773
+ );
3774
+ }
3775
+ const body = await readJsonBody(req);
3776
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3777
+ const payload = asRecord(body.value);
3778
+ const outcome = ctx.runtime.roleWrite.add(
3779
+ {
3780
+ name: typeof payload["name"] === "string" ? payload["name"].trim() : "",
3781
+ description: typeof payload["description"] === "string" ? payload["description"].trim() : "",
3782
+ ...typeof payload["prompt"] === "string" ? { prompt: payload["prompt"] } : {},
3783
+ ...Array.isArray(payload["tools"]) ? { tools: stringList2(payload["tools"]) } : {},
3784
+ ...Array.isArray(payload["skills"]) ? { skills: stringList2(payload["skills"]) } : {},
3785
+ ...typeof payload["maxTurns"] === "number" ? { maxTurns: payload["maxTurns"] } : {}
3786
+ },
3787
+ lang
3788
+ );
3789
+ if (!outcome.ok) return sendError(res, ...addFailure2(outcome.reason, outcome.detail, lang));
3790
+ sendJson(res, 200, {
3791
+ role: toWireRole(outcome.role, ctx.runtime.agentRoleNotices),
3792
+ path: outcome.path
3793
+ });
3794
+ }
3795
+ function addFailure2(reason, detail, lang) {
3796
+ if (reason === "invalid-name") return [400, "bad-role-name", detail];
3797
+ if (reason === "invalid-field")
3798
+ return [400, "bad-role-field", t("web.role_add_bad_field", { detail }, lang)];
3799
+ if (reason === "unknown-tool") return [400, "bad-role-tools", detail];
3800
+ if (reason === "duplicate") return [409, "role-name-taken", detail];
3801
+ return [409, "role-unwritable", detail];
3802
+ }
3803
+ function stringList2(value) {
3804
+ return value.filter((v) => typeof v === "string");
3805
+ }
3806
+ async function removeRole(ctx, req, res, lang) {
3807
+ if (ctx.lanExposed) {
3808
+ return sendError(
3809
+ res,
3810
+ 403,
3811
+ "role-remove-lan-exposed",
3812
+ t("web.role_remove_lan_exposed", void 0, lang)
3813
+ );
3814
+ }
3815
+ const body = await readJsonBody(req);
3816
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3817
+ const payload = asRecord(body.value);
3818
+ const name = typeof payload["name"] === "string" ? payload["name"].trim() : "";
3819
+ const outcome = ctx.runtime.roleWrite.remove(name, lang);
3820
+ if (!outcome.ok) {
3821
+ return sendJson(res, 200, {
3822
+ ok: false,
3823
+ name: "",
3824
+ path: null,
3825
+ takenOverBy: null,
3826
+ detail: outcome.detail,
3827
+ reason: outcome.reason
3828
+ });
3829
+ }
3830
+ sendJson(res, 200, {
3831
+ ok: true,
3832
+ name: outcome.name,
3833
+ path: outcome.path,
3834
+ takenOverBy: outcome.takenOverBy,
3835
+ detail: null,
3836
+ reason: null
3837
+ });
3838
+ }
3839
+ function stringList3(value) {
3840
+ if (!Array.isArray(value)) return null;
3841
+ const out = [];
3842
+ for (const item of value) {
3843
+ if (typeof item !== "string" || item.trim().length === 0) return null;
3844
+ out.push(item);
3845
+ }
3846
+ return out;
3847
+ }
3848
+ function operationList(value) {
3849
+ if (!Array.isArray(value)) return null;
3850
+ const out = [];
3851
+ for (const item of value) {
3852
+ if (typeof item !== "string" || !isOperationType(item)) return null;
3853
+ out.push(item);
3854
+ }
3855
+ return out;
3856
+ }
3857
+ function finite(value) {
3858
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
3859
+ }
3860
+ function parseTrigger(value) {
3345
3861
  const raw = asRecord(value);
3346
3862
  const kind = raw["kind"];
3347
3863
  const at = raw["at"];
@@ -4177,681 +4693,315 @@ function browseWorkspaceDirs(ctx, res, rawUrl, lang) {
4177
4693
  return sendError(
4178
4694
  res,
4179
4695
  403,
4180
- "workspace-denied",
4181
- t("web.dirs_denied", { path: outcome.path }, lang)
4182
- );
4183
- }
4184
- if (outcome.reason === "missing") {
4185
- return sendError(res, 404, "not-found", t("web.dirs_missing", { path: outcome.path }, lang));
4186
- }
4187
- sendError(
4188
- res,
4189
- 400,
4190
- "bad-workspace",
4191
- outcome.reason === "not-absolute" ? t("web.dirs_not_absolute", { path: outcome.path }, lang) : t("web.dirs_not_a_directory", { path: outcome.path }, lang)
4192
- );
4193
- }
4194
- function dirsQuery(query) {
4195
- const value = query.get("path")?.trim();
4196
- return value ? value : void 0;
4197
- }
4198
- function atQuery(query) {
4199
- return query.get("at")?.trim().toLowerCase() === "home" ? workspaceHome() : void 0;
4200
- }
4201
- function hiddenQuery(query) {
4202
- const value = query.get("hidden")?.trim().toLowerCase();
4203
- return value === "1" || value === "true";
4204
- }
4205
- async function createWorkspaceDir(req, res, lang) {
4206
- const body = await readJsonBody(req);
4207
- if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
4208
- const payload = asRecord(body.value);
4209
- const parent = typeof payload["parent"] === "string" ? payload["parent"].trim() : "";
4210
- const name = typeof payload["name"] === "string" ? payload["name"].trim() : "";
4211
- if (parent.length === 0 || !isAbsolute(parent)) {
4212
- return sendError(
4213
- res,
4214
- 400,
4215
- "bad-workspace",
4216
- t("web.mkdir_parent_not_absolute", { parent }, lang)
4217
- );
4218
- }
4219
- if (!isSingleSegment(name)) {
4220
- return sendError(res, 400, "bad-workspace", t("web.mkdir_bad_name", { name }, lang));
4221
- }
4222
- const path = resolve(parent, name);
4223
- try {
4224
- mkdirSync(path);
4225
- } catch (cause) {
4226
- return sendError(res, ...mkdirFailure(cause, path, lang));
4227
- }
4228
- sendJson(res, 200, {
4229
- entry: {
4230
- name,
4231
- path,
4232
- readable: true,
4233
- known: false,
4234
- group: "child"
4235
- }
4236
- });
4237
- }
4238
- async function pickWorkspaceDir(ctx, req, res, lang) {
4239
- if (!ctx.nativeDirPicker) {
4240
- return sendError(res, 403, "native-picker-unavailable", t("web.pick_unavailable", {}, lang));
4241
- }
4242
- const abort = new AbortController();
4243
- req.on("close", () => abort.abort());
4244
- const outcome = await pickNativeDirectory({
4245
- prompt: t("web.pick_prompt", {}, lang),
4246
- signal: abort.signal
4247
- });
4248
- if (res.writableEnded || abort.signal.aborted) return;
4249
- if (outcome.ok) {
4250
- return sendJson(
4251
- res,
4252
- 200,
4253
- outcome.path === void 0 ? { canceled: true } : { path: outcome.path }
4254
- );
4255
- }
4256
- if (outcome.reason === "busy") {
4257
- return sendError(res, 409, "native-picker-busy", t("web.pick_busy", {}, lang));
4258
- }
4259
- sendError(
4260
- res,
4261
- 500,
4262
- "native-picker-failed",
4263
- t("web.pick_failed", { detail: outcome.message }, lang)
4264
- );
4265
- }
4266
- function isSingleSegment(name) {
4267
- if (name.length === 0) return false;
4268
- if (name === "." || name === "..") return false;
4269
- return !name.includes("/") && !name.includes(sep);
4270
- }
4271
- function mkdirFailure(cause, path, lang) {
4272
- const code = cause?.code;
4273
- if (code === "EEXIST") return [409, "workspace-exists", t("web.mkdir_exists", { path }, lang)];
4274
- if (code === "EACCES" || code === "EPERM") {
4275
- return [403, "workspace-denied", t("web.mkdir_denied", { path }, lang)];
4276
- }
4277
- if (code === "ENOENT") return [404, "not-found", t("web.mkdir_parent_missing", { path }, lang)];
4278
- return [500, "internal", t("web.mkdir_failed", { path }, lang)];
4279
- }
4280
- var MAX_TEXT_BYTES = 2 * 1024 * 1024;
4281
- var VIEWABLE = /* @__PURE__ */ new Map([
4282
- [".png", { kind: "image", mime: "image/png" }],
4283
- [".jpg", { kind: "image", mime: "image/jpeg" }],
4284
- [".jpeg", { kind: "image", mime: "image/jpeg" }],
4285
- [".gif", { kind: "image", mime: "image/gif" }],
4286
- [".webp", { kind: "image", mime: "image/webp" }],
4287
- [".bmp", { kind: "image", mime: "image/bmp" }],
4288
- [".ico", { kind: "image", mime: "image/x-icon" }],
4289
- [".avif", { kind: "image", mime: "image/avif" }],
4290
- [".pdf", { kind: "pdf", mime: "application/pdf" }],
4291
- [".mp3", { kind: "audio", mime: "audio/mpeg" }],
4292
- [".wav", { kind: "audio", mime: "audio/wav" }],
4293
- [".ogg", { kind: "audio", mime: "audio/ogg" }],
4294
- [".m4a", { kind: "audio", mime: "audio/mp4" }],
4295
- [".flac", { kind: "audio", mime: "audio/flac" }],
4296
- [".mp4", { kind: "video", mime: "video/mp4" }],
4297
- [".webm", { kind: "video", mime: "video/webm" }],
4298
- [".mov", { kind: "video", mime: "video/quicktime" }]
4299
- ]);
4300
- function kindOf(stat3) {
4301
- if (stat3.kind === "text") return "text";
4302
- return VIEWABLE.get(extname(stat3.rel).toLowerCase())?.kind ?? "opaque";
4303
- }
4304
- function toWireRefusal(reason) {
4305
- return reason;
4306
- }
4307
- function statusFor(refusal) {
4308
- if (refusal === "denied") return 403;
4309
- if (refusal === "missing") return 404;
4310
- if (refusal === "too-large") return 413;
4311
- return refusal === "not-a-file" ? 400 : 503;
4312
- }
4313
- function describeRefusal(refusal, lang) {
4314
- if (refusal === "denied") return t("web.file_view_denied", void 0, lang);
4315
- if (refusal === "missing") return t("web.file_view_missing", void 0, lang);
4316
- if (refusal === "not-a-file") return t("web.file_view_not_a_file", void 0, lang);
4317
- if (refusal === "too-large") {
4318
- return t("web.file_view_too_large", { max: String(MAX_TEXT_BYTES) }, lang);
4319
- }
4320
- return t("web.file_view_unreadable", void 0, lang);
4321
- }
4322
- function resolveViewRoot(ctx, res, sessionId, lang) {
4323
- if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
4324
- sendUnknownSession(res, sessionId);
4325
- return null;
4326
- }
4327
- const root = workspaceRootOf(ctx, sessionId);
4328
- if (!root) {
4329
- sendError(res, 409, "no-workspace", t("web.file_view_no_workspace", void 0, lang));
4330
- return null;
4331
- }
4332
- return root;
4333
- }
4334
- function singlePath(res, rawUrl, lang) {
4335
- const params = new URLSearchParams((rawUrl ?? "").split("?")[1] ?? "");
4336
- const path = params.get("path")?.trim();
4337
- if (!path) {
4338
- sendError(res, 400, "missing-path", t("web.file_view_missing_path", void 0, lang));
4339
- return null;
4340
- }
4341
- return path;
4342
- }
4343
- function statFiles(ctx, res, sessionId, rawUrl, lang) {
4344
- const root = resolveViewRoot(ctx, res, sessionId, lang);
4345
- if (root === null) return;
4346
- const params = new URLSearchParams((rawUrl ?? "").split("?")[1] ?? "");
4347
- const asked = params.getAll("path").filter((p) => p.trim() !== "");
4348
- const taken = asked.slice(0, WIRE_FILE_STAT_MAX);
4349
- const files = taken.map((path) => {
4350
- const stat3 = ctx.runtime.workspaceFiles.statFile(path, root);
4351
- if (!stat3.ok) return { path, ok: false, refusal: toWireRefusal(stat3.reason) };
4352
- return {
4353
- path,
4354
- ok: true,
4355
- kind: kindOf(stat3),
4356
- bytes: stat3.bytes,
4357
- mtimeMs: stat3.mtimeMs,
4358
- openable: ctx.sameMachine && isSystemOpenable(stat3.rel)
4359
- };
4360
- });
4361
- sendJson(res, 200, {
4362
- files,
4363
- overLimit: asked.length - taken.length,
4364
- root
4365
- });
4366
- }
4367
- function readFileText(ctx, res, sessionId, rawUrl, lang) {
4368
- const root = resolveViewRoot(ctx, res, sessionId, lang);
4369
- if (root === null) return;
4370
- const path = singlePath(res, rawUrl, lang);
4371
- if (path === null) return;
4372
- const stat3 = ctx.runtime.workspaceFiles.statFile(path, root);
4373
- if (!stat3.ok) {
4374
- const refusal = toWireRefusal(stat3.reason);
4375
- return sendError(res, statusFor(refusal), refusal, describeRefusal(refusal, lang));
4376
- }
4377
- if (stat3.kind !== "text") {
4378
- return sendError(res, 415, "not-text", t("web.file_view_not_text", void 0, lang));
4379
- }
4380
- if (stat3.bytes > MAX_TEXT_BYTES) {
4381
- return sendError(res, 413, "too-large", describeRefusal("too-large", lang));
4382
- }
4383
- const out = ctx.runtime.workspaceFiles.readFile(stat3.rel, root);
4384
- if (!out.ok) {
4385
- const refusal = out.reason === "binary" ? "unreadable" : out.reason;
4386
- return sendError(res, statusFor(refusal), refusal, describeRefusal(refusal, lang));
4387
- }
4388
- sendJson(res, 200, {
4389
- path,
4390
- text: out.text,
4391
- bytes: out.bytes
4392
- });
4393
- }
4394
- function readFileBytes(ctx, res, sessionId, rawUrl, headOnly, lang) {
4395
- const root = resolveViewRoot(ctx, res, sessionId, lang);
4396
- if (root === null) return;
4397
- const path = singlePath(res, rawUrl, lang);
4398
- if (path === null) return;
4399
- const stat3 = ctx.runtime.workspaceFiles.statFile(path, root);
4400
- if (!stat3.ok) {
4401
- const refusal = toWireRefusal(stat3.reason);
4402
- return sendError(res, statusFor(refusal), refusal, describeRefusal(refusal, lang));
4403
- }
4404
- const known = VIEWABLE.get(extname(stat3.rel).toLowerCase());
4405
- const inline = known !== void 0 && stat3.kind === "binary";
4406
- const headers = {
4407
- "Content-Type": inline ? known.mime : "application/octet-stream",
4408
- "X-Content-Type-Options": "nosniff",
4409
- "Content-Disposition": inline ? "inline" : `attachment; filename*=UTF-8''${encodeURIComponent(basenameOf(stat3.rel))}`
4410
- };
4411
- if (inline && known.kind === "pdf") headers["Content-Security-Policy"] = "sandbox";
4412
- if (headOnly) {
4413
- return sendEmpty(res, 200, {
4414
- ...headers,
4415
- "Content-Length": String(stat3.bytes)
4416
- });
4417
- }
4418
- sendFile(res, stat3.absPath, headers);
4419
- }
4420
- function basenameOf(rel) {
4421
- const cut = rel.lastIndexOf("/");
4422
- return cut < 0 ? rel : rel.slice(cut + 1);
4423
- }
4424
- // src/workspace/file-open.ts
4425
- function describeOpenRefusal(refusal, lang) {
4426
- if (refusal === "extension-not-allowed") {
4427
- return t("web.file_open_extension_not_allowed", void 0, lang);
4428
- }
4429
- if (refusal === "not-same-machine") return t("web.file_open_not_same_machine", void 0, lang);
4430
- if (refusal === "launch-failed") return t("web.file_open_launch_failed", void 0, lang);
4431
- return describeRefusal(refusal, lang);
4432
- }
4433
- function refuse(res, refusal, lang) {
4434
- sendJson(res, 200, {
4435
- ok: false,
4436
- refusal,
4437
- message: describeOpenRefusal(refusal, lang)
4438
- });
4439
- }
4440
- async function openFileWithSystemApp(ctx, req, res, sessionId, lang) {
4441
- if (!ctx.sameMachine) {
4442
- return sendError(res, 403, "not-same-machine", describeOpenRefusal("not-same-machine", lang));
4443
- }
4444
- const root = resolveViewRoot(ctx, res, sessionId, lang);
4445
- if (root === null) return;
4446
- const body = await readJsonBody(req);
4447
- if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
4448
- const payload = asRecord(body.value);
4449
- const path = typeof payload["path"] === "string" ? payload["path"].trim() : "";
4450
- if (path === "") {
4451
- return sendError(res, 400, "missing-path", t("web.file_view_missing_path", void 0, lang));
4452
- }
4453
- const plan = ctx.runtime.workspaceFiles.planSystemOpen(path, root);
4454
- if (!plan.ok) return refuse(res, plan.reason, lang);
4455
- try {
4456
- const child = spawn(plan.command, [...plan.args], { stdio: "ignore", detached: true });
4457
- await new Promise((done, fail) => {
4458
- child.on("error", fail);
4459
- child.on("spawn", () => {
4460
- child.unref();
4461
- done();
4462
- });
4463
- });
4464
- } catch {
4465
- return refuse(res, "launch-failed", lang);
4466
- }
4467
- sendJson(res, 200, { ok: true });
4468
- }
4469
- async function forgetWorkspace(ctx, sessionId, req, res, lang) {
4470
- const body = await readJsonBody(req, void 0, lang);
4471
- if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
4472
- if (!conformsToWire(body.value, WIRE_FORGET_WORKSPACE_FIELDS)) {
4473
- return sendError(res, 400, "bad-request", t("web.forget_bad_request", {}, lang));
4474
- }
4475
- const { root } = body.value;
4476
- const workspaces = ctx.runtime.workspaces;
4477
- if (!workspaces.known().some((w) => w.root === root)) {
4478
- return sendError(res, 404, "unknown-workspace", t("web.forget_unknown", {}, lang));
4479
- }
4480
- const current = workspaces.of(sessionId)?.workspace.root;
4481
- if (current !== void 0 && current === root) {
4482
- return sendError(res, 409, "workspace-in-use", t("web.forget_in_use", {}, lang));
4483
- }
4484
- workspaces.forget(root);
4485
- sendJson(res, 200, {
4486
- known: toWireTrustList(workspaces.known(), workspaces, current ?? null)
4487
- });
4488
- }
4489
- var GIT_TIMEOUT_MS = 1e4;
4490
- var GIT_MAX_BUFFER = 16 * 1024 * 1024;
4491
- function runGit(cwd, args) {
4492
- if (!canProbeGit()) return Promise.resolve({ ok: false, error: "git unavailable" });
4493
- return new Promise((resolve4) => {
4494
- guardSpawn(
4495
- () => execFile(
4496
- "git",
4497
- [...args],
4498
- { cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BUFFER, encoding: "buffer" },
4499
- (err, stdout) => {
4500
- if (err) return resolve4({ ok: false, error: err.message });
4501
- resolve4({ ok: true, stdout });
4502
- }
4503
- ),
4504
- (err) => resolve4({ ok: false, error: `${err.message} (cwd=${cwd})` })
4505
- );
4506
- });
4507
- }
4508
- async function repoRoot(cwd) {
4509
- const result = await runGit(cwd, ["rev-parse", "--show-toplevel"]);
4510
- if (!result.ok) return null;
4511
- const root = result.stdout.toString("utf-8").trim();
4512
- return root.length > 0 ? root : null;
4513
- }
4514
- async function hasHead(root) {
4515
- return (await runGit(root, ["rev-parse", "--verify", "--quiet", "HEAD"])).ok;
4516
- }
4517
- var NULL_SHA = /^0+$/;
4518
- async function rawChanges(root) {
4519
- const result = await runGit(root, ["diff", "--raw", "-z", "HEAD", "--"]);
4520
- if (!result.ok) return [];
4521
- return parseRawZ(result.stdout.toString("utf-8"));
4522
- }
4523
- function parseRawZ(text) {
4524
- const tokens = text.split("\0");
4525
- const changes = [];
4526
- let i = 0;
4527
- while (i < tokens.length) {
4528
- const meta = tokens[i];
4529
- if (meta === void 0 || !meta.startsWith(":")) {
4530
- i += 1;
4531
- continue;
4532
- }
4533
- const parts = meta.slice(1).split(" ");
4534
- const oldSha = parts[2] ?? "";
4535
- const status = (parts[4] ?? "").trim();
4536
- const renamed = status.startsWith("R") || status.startsWith("C");
4537
- const first = tokens[i + 1];
4538
- const second = renamed ? tokens[i + 2] : void 0;
4539
- i += renamed ? 3 : 2;
4540
- if (first === void 0 || renamed && second === void 0) break;
4541
- const path = renamed ? second : first;
4542
- changes.push({
4543
- status: status.charAt(0),
4544
- path,
4545
- ...renamed ? { oldPath: first } : {},
4546
- oldSha: NULL_SHA.test(oldSha) ? null : oldSha
4547
- });
4696
+ "workspace-denied",
4697
+ t("web.dirs_denied", { path: outcome.path }, lang)
4698
+ );
4548
4699
  }
4549
- return changes;
4550
- }
4551
- async function treeBlobSizes(root) {
4552
- const sizes = /* @__PURE__ */ new Map();
4553
- const result = await runGit(root, ["ls-tree", "-r", "-l", "-z", "HEAD"]);
4554
- if (!result.ok) return sizes;
4555
- for (const record of splitZ(result.stdout.toString("utf-8"))) {
4556
- const tab = record.indexOf(" ");
4557
- if (tab < 0) continue;
4558
- const columns = record.slice(0, tab).split(/\s+/).filter(Boolean);
4559
- const sha = columns[2];
4560
- const size = Number(columns[3]);
4561
- if (columns[1] === "blob" && sha && Number.isFinite(size)) sizes.set(sha, size);
4700
+ if (outcome.reason === "missing") {
4701
+ return sendError(res, 404, "not-found", t("web.dirs_missing", { path: outcome.path }, lang));
4562
4702
  }
4563
- return sizes;
4564
- }
4565
- async function untrackedFiles(root) {
4566
- const result = await runGit(root, ["ls-files", "-z", "--others", "--exclude-standard"]);
4567
- if (!result.ok) return [];
4568
- return splitZ(result.stdout.toString("utf-8"));
4569
- }
4570
- async function trackedFiles(root) {
4571
- const result = await runGit(root, ["ls-files", "-z"]);
4572
- if (!result.ok) return [];
4573
- return splitZ(result.stdout.toString("utf-8"));
4703
+ sendError(
4704
+ res,
4705
+ 400,
4706
+ "bad-workspace",
4707
+ outcome.reason === "not-absolute" ? t("web.dirs_not_absolute", { path: outcome.path }, lang) : t("web.dirs_not_a_directory", { path: outcome.path }, lang)
4708
+ );
4574
4709
  }
4575
- async function catFileBlob(root, sha) {
4576
- const result = await runGit(root, ["cat-file", "blob", sha]);
4577
- return result.ok ? result.stdout : null;
4710
+ function dirsQuery(query) {
4711
+ const value = query.get("path")?.trim();
4712
+ return value ? value : void 0;
4578
4713
  }
4579
- function splitZ(text) {
4580
- return text.split("\0").filter((s) => s.length > 0);
4714
+ function atQuery(query) {
4715
+ return query.get("at")?.trim().toLowerCase() === "home" ? workspaceHome() : void 0;
4581
4716
  }
4582
- // src/workspace/diff.ts
4583
- var MAX_DIFF_BYTES = 512 * 1024;
4584
- var MAX_DIFF_FILES = 200;
4585
- var BINARY_SNIFF_BYTES = 8e3;
4586
- var ABSENT = { kind: "absent" };
4587
- async function collectWorkspaceDiff(opts) {
4588
- const root = await repoRoot(opts.workDir);
4589
- return root === null ? await checkpointDiff(opts) : await gitDiff(root);
4717
+ function hiddenQuery(query) {
4718
+ const value = query.get("hidden")?.trim().toLowerCase();
4719
+ return value === "1" || value === "true";
4590
4720
  }
4591
- async function gitDiff(root) {
4592
- const wanted = await collectGitPaths(root);
4593
- const notes = [];
4594
- const truncated = wanted.length > MAX_DIFF_FILES;
4595
- const picked = truncated ? wanted.slice(0, MAX_DIFF_FILES) : wanted;
4596
- if (truncated) notes.push("truncated");
4597
- if (wanted.length === 0) notes.push("no-changes");
4598
- const sizes = picked.some((p) => p.oldSha !== null) ? await treeBlobSizes(root) : /* @__PURE__ */ new Map();
4599
- const files = [];
4600
- for (const item of picked) {
4601
- const old = await gitSide(root, item.oldSha, sizes);
4602
- const fresh = await worktreeSide(join(root, ...item.path.split("/")));
4603
- files.push(describe3(item.path, old, fresh));
4721
+ async function createWorkspaceDir(req, res, lang) {
4722
+ const body = await readJsonBody(req);
4723
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
4724
+ const payload = asRecord(body.value);
4725
+ const parent = typeof payload["parent"] === "string" ? payload["parent"].trim() : "";
4726
+ const name = typeof payload["name"] === "string" ? payload["name"].trim() : "";
4727
+ if (parent.length === 0 || !isAbsolute(parent)) {
4728
+ return sendError(
4729
+ res,
4730
+ 400,
4731
+ "bad-workspace",
4732
+ t("web.mkdir_parent_not_absolute", { parent }, lang)
4733
+ );
4604
4734
  }
4605
- return { source: "git", root, files, truncated, notes };
4606
- }
4607
- async function collectGitPaths(root) {
4608
- const byPath = /* @__PURE__ */ new Map();
4609
- if (await hasHead(root)) {
4610
- for (const change of await rawChanges(root)) {
4611
- if (change.oldPath !== void 0) {
4612
- byPath.set(change.oldPath, { path: change.oldPath, oldSha: change.oldSha });
4613
- }
4614
- byPath.set(change.path, {
4615
- path: change.path,
4616
- oldSha: change.oldPath === void 0 ? change.oldSha : null
4617
- });
4618
- }
4619
- } else {
4620
- for (const path of await trackedFiles(root)) byPath.set(path, { path, oldSha: null });
4735
+ if (!isSingleSegment(name)) {
4736
+ return sendError(res, 400, "bad-workspace", t("web.mkdir_bad_name", { name }, lang));
4621
4737
  }
4622
- for (const path of await untrackedFiles(root)) {
4623
- if (!byPath.has(path)) byPath.set(path, { path, oldSha: null });
4738
+ const path = resolve(parent, name);
4739
+ try {
4740
+ mkdirSync(path);
4741
+ } catch (cause) {
4742
+ return sendError(res, ...mkdirFailure(cause, path, lang));
4624
4743
  }
4625
- return [...byPath.values()].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
4626
- }
4627
- async function gitSide(root, sha, sizes) {
4628
- if (sha === null) return ABSENT;
4629
- const known = sizes.get(sha);
4630
- if (known !== void 0 && known > MAX_DIFF_BYTES) return { kind: "oversize", bytes: known };
4631
- const data = await catFileBlob(root, sha);
4632
- if (data === null) return { kind: "oversize", bytes: known ?? -1 };
4633
- return { kind: "content", data };
4744
+ sendJson(res, 200, {
4745
+ entry: {
4746
+ name,
4747
+ path,
4748
+ readable: true,
4749
+ known: false,
4750
+ group: "child"
4751
+ }
4752
+ });
4634
4753
  }
4635
- async function checkpointDiff(opts) {
4636
- const notes = ["not-a-git-repo"];
4637
- const paths = await checkpointPaths(opts.checkpoints);
4638
- if (paths.length === 0) {
4639
- notes.push(opts.checkpoints === null ? "no-workspace-history" : "no-changes");
4640
- return { source: "none", root: opts.workDir, files: [], truncated: false, notes };
4754
+ async function pickWorkspaceDir(ctx, req, res, lang) {
4755
+ if (!ctx.nativeDirPicker) {
4756
+ return sendError(res, 403, "native-picker-unavailable", t("web.pick_unavailable", {}, lang));
4641
4757
  }
4642
- const truncated = paths.length > MAX_DIFF_FILES;
4643
- const picked = truncated ? paths.slice(0, MAX_DIFF_FILES) : paths;
4644
- if (truncated) notes.push("truncated");
4645
- const files = [];
4646
- for (const absolute of picked) {
4647
- const fresh = await worktreeSide(absolute);
4648
- files.push(
4649
- describe3(displayPath(opts.workDir, absolute), ABSENT, fresh, {
4650
- status: fresh.kind === "absent" ? "deleted" : "modified",
4651
- omitted: "no-snapshot"
4652
- })
4758
+ const abort = new AbortController();
4759
+ req.on("close", () => abort.abort());
4760
+ const outcome = await pickNativeDirectory({
4761
+ prompt: t("web.pick_prompt", {}, lang),
4762
+ signal: abort.signal
4763
+ });
4764
+ if (res.writableEnded || abort.signal.aborted) return;
4765
+ if (outcome.ok) {
4766
+ return sendJson(
4767
+ res,
4768
+ 200,
4769
+ outcome.path === void 0 ? { canceled: true } : { path: outcome.path }
4653
4770
  );
4654
4771
  }
4655
- return { source: "checkpoints", root: opts.workDir, files, truncated, notes };
4656
- }
4657
- async function checkpointPaths(checkpoints) {
4658
- if (!checkpoints) return [];
4659
- const paths = /* @__PURE__ */ new Set();
4660
- try {
4661
- for (const summary of await checkpoints.list()) {
4662
- const preview = await checkpoints.preview(summary.turnIndex);
4663
- if (!preview) continue;
4664
- for (const plan of [...preview.files, ...preview.conflicts]) {
4665
- if (plan.action !== "skip") paths.add(plan.path);
4666
- }
4667
- }
4668
- } catch {
4772
+ if (outcome.reason === "busy") {
4773
+ return sendError(res, 409, "native-picker-busy", t("web.pick_busy", {}, lang));
4669
4774
  }
4670
- return [...paths].sort();
4775
+ sendError(
4776
+ res,
4777
+ 500,
4778
+ "native-picker-failed",
4779
+ t("web.pick_failed", { detail: outcome.message }, lang)
4780
+ );
4671
4781
  }
4672
- function displayPath(workDir, absolute) {
4673
- const rel = relative(workDir, absolute);
4674
- if (rel.length === 0 || rel.startsWith("..") || isAbsolute(rel)) return absolute;
4675
- return rel.split(sep).join("/");
4782
+ function isSingleSegment(name) {
4783
+ if (name.length === 0) return false;
4784
+ if (name === "." || name === "..") return false;
4785
+ return !name.includes("/") && !name.includes(sep);
4676
4786
  }
4677
- async function worktreeSide(absolute) {
4678
- try {
4679
- const info = await stat(absolute);
4680
- if (!info.isFile()) return ABSENT;
4681
- if (info.size > MAX_DIFF_BYTES) return { kind: "oversize", bytes: info.size };
4682
- return { kind: "content", data: await readFile(absolute) };
4683
- } catch {
4684
- return ABSENT;
4787
+ function mkdirFailure(cause, path, lang) {
4788
+ const code = cause?.code;
4789
+ if (code === "EEXIST") return [409, "workspace-exists", t("web.mkdir_exists", { path }, lang)];
4790
+ if (code === "EACCES" || code === "EPERM") {
4791
+ return [403, "workspace-denied", t("web.mkdir_denied", { path }, lang)];
4685
4792
  }
4793
+ if (code === "ENOENT") return [404, "not-found", t("web.mkdir_parent_missing", { path }, lang)];
4794
+ return [500, "internal", t("web.mkdir_failed", { path }, lang)];
4686
4795
  }
4687
- function describe3(path, old, fresh, override = {}) {
4688
- const status = override.status ?? (old.kind === "absent" ? "added" : fresh.kind === "absent" ? "deleted" : "modified");
4689
- const oldBytes = bytesOf(old);
4690
- const newBytes = bytesOf(fresh);
4691
- const hard = omitReason(old, fresh);
4692
- if (hard !== void 0) {
4693
- return { path, status, oldContent: null, newContent: null, oldBytes, newBytes, omitted: hard };
4694
- }
4695
- return {
4696
- path,
4697
- status,
4698
- oldContent: textOf(old),
4699
- newContent: textOf(fresh),
4700
- oldBytes,
4701
- newBytes,
4702
- ...override.omitted === void 0 ? {} : { omitted: override.omitted }
4703
- };
4796
+ var MAX_TEXT_BYTES = 2 * 1024 * 1024;
4797
+ var VIEWABLE = /* @__PURE__ */ new Map([
4798
+ [".png", { kind: "image", mime: "image/png" }],
4799
+ [".jpg", { kind: "image", mime: "image/jpeg" }],
4800
+ [".jpeg", { kind: "image", mime: "image/jpeg" }],
4801
+ [".gif", { kind: "image", mime: "image/gif" }],
4802
+ [".webp", { kind: "image", mime: "image/webp" }],
4803
+ [".bmp", { kind: "image", mime: "image/bmp" }],
4804
+ [".ico", { kind: "image", mime: "image/x-icon" }],
4805
+ [".avif", { kind: "image", mime: "image/avif" }],
4806
+ [".pdf", { kind: "pdf", mime: "application/pdf" }],
4807
+ [".mp3", { kind: "audio", mime: "audio/mpeg" }],
4808
+ [".wav", { kind: "audio", mime: "audio/wav" }],
4809
+ [".ogg", { kind: "audio", mime: "audio/ogg" }],
4810
+ [".m4a", { kind: "audio", mime: "audio/mp4" }],
4811
+ [".flac", { kind: "audio", mime: "audio/flac" }],
4812
+ [".mp4", { kind: "video", mime: "video/mp4" }],
4813
+ [".webm", { kind: "video", mime: "video/webm" }],
4814
+ [".mov", { kind: "video", mime: "video/quicktime" }]
4815
+ ]);
4816
+ function kindOf(stat3) {
4817
+ if (stat3.kind === "text") return "text";
4818
+ return VIEWABLE.get(extname(stat3.rel).toLowerCase())?.kind ?? "opaque";
4704
4819
  }
4705
- function bytesOf(side) {
4706
- if (side.kind === "absent") return null;
4707
- if (side.kind === "oversize") return side.bytes < 0 ? null : side.bytes;
4708
- return side.data.byteLength;
4820
+ function toWireRefusal(reason) {
4821
+ return reason;
4822
+ }
4823
+ function statusFor(refusal) {
4824
+ if (refusal === "denied") return 403;
4825
+ if (refusal === "missing") return 404;
4826
+ if (refusal === "too-large") return 413;
4827
+ return refusal === "not-a-file" ? 400 : 503;
4828
+ }
4829
+ function describeRefusal(refusal, lang) {
4830
+ if (refusal === "denied") return t("web.file_view_denied", void 0, lang);
4831
+ if (refusal === "missing") return t("web.file_view_missing", void 0, lang);
4832
+ if (refusal === "not-a-file") return t("web.file_view_not_a_file", void 0, lang);
4833
+ if (refusal === "too-large") {
4834
+ return t("web.file_view_too_large", { max: String(MAX_TEXT_BYTES) }, lang);
4835
+ }
4836
+ return t("web.file_view_unreadable", void 0, lang);
4709
4837
  }
4710
- function textOf(side) {
4711
- return side.kind === "content" ? side.data.toString("utf-8") : null;
4838
+ function resolveViewRoot(ctx, res, sessionId, lang) {
4839
+ if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
4840
+ sendUnknownSession(res, sessionId);
4841
+ return null;
4842
+ }
4843
+ const root = workspaceRootOf(ctx, sessionId);
4844
+ if (!root) {
4845
+ sendError(res, 409, "no-workspace", t("web.file_view_no_workspace", void 0, lang));
4846
+ return null;
4847
+ }
4848
+ return root;
4712
4849
  }
4713
- function omitReason(old, fresh) {
4714
- if (isBinary(old) || isBinary(fresh)) return "binary";
4715
- if (old.kind === "oversize" || fresh.kind === "oversize") return "too-large";
4716
- return void 0;
4850
+ function singlePath(res, rawUrl, lang) {
4851
+ const params = new URLSearchParams((rawUrl ?? "").split("?")[1] ?? "");
4852
+ const path = params.get("path")?.trim();
4853
+ if (!path) {
4854
+ sendError(res, 400, "missing-path", t("web.file_view_missing_path", void 0, lang));
4855
+ return null;
4856
+ }
4857
+ return path;
4717
4858
  }
4718
- function isBinary(side) {
4719
- return side.kind === "content" && side.data.subarray(0, BINARY_SNIFF_BYTES).includes(0);
4859
+ function statFiles(ctx, res, sessionId, rawUrl, lang) {
4860
+ const root = resolveViewRoot(ctx, res, sessionId, lang);
4861
+ if (root === null) return;
4862
+ const params = new URLSearchParams((rawUrl ?? "").split("?")[1] ?? "");
4863
+ const asked = params.getAll("path").filter((p) => p.trim() !== "");
4864
+ const taken = asked.slice(0, WIRE_FILE_STAT_MAX);
4865
+ const files = taken.map((path) => {
4866
+ const stat3 = ctx.runtime.workspaceFiles.statFile(path, root);
4867
+ if (!stat3.ok) return { path, ok: false, refusal: toWireRefusal(stat3.reason) };
4868
+ return {
4869
+ path,
4870
+ ok: true,
4871
+ kind: kindOf(stat3),
4872
+ bytes: stat3.bytes,
4873
+ mtimeMs: stat3.mtimeMs,
4874
+ openable: ctx.sameMachine && isSystemOpenable(stat3.rel)
4875
+ };
4876
+ });
4877
+ sendJson(res, 200, {
4878
+ files,
4879
+ overLimit: asked.length - taken.length,
4880
+ root
4881
+ });
4720
4882
  }
4721
- // src/workspace/handlers.ts
4722
- async function createSession(ctx, req, res, lang) {
4723
- const body = await readJsonBody(req);
4724
- if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
4725
- const payload = asRecord(body.value);
4726
- const field = readWorkspaceField(payload, lang);
4727
- if (!field.ok) return sendError(res, 400, "bad-workspace", field.message);
4728
- const defer = payload["deferWorkspace"] === true;
4729
- if (defer && field.dir !== null) {
4730
- return sendError(res, 400, "bad-request", t("web.workspace_defer_with_root", void 0, lang));
4883
+ function readFileText(ctx, res, sessionId, rawUrl, lang) {
4884
+ const root = resolveViewRoot(ctx, res, sessionId, lang);
4885
+ if (root === null) return;
4886
+ const path = singlePath(res, rawUrl, lang);
4887
+ if (path === null) return;
4888
+ const stat3 = ctx.runtime.workspaceFiles.statFile(path, root);
4889
+ if (!stat3.ok) {
4890
+ const refusal = toWireRefusal(stat3.reason);
4891
+ return sendError(res, statusFor(refusal), refusal, describeRefusal(refusal, lang));
4731
4892
  }
4732
- if (defer && payload["create"] !== true) {
4733
- return sendError(
4734
- res,
4735
- 400,
4736
- "bad-request",
4737
- t("web.workspace_defer_needs_create", void 0, lang)
4738
- );
4893
+ if (stat3.kind !== "text") {
4894
+ return sendError(res, 415, "not-text", t("web.file_view_not_text", void 0, lang));
4739
4895
  }
4740
- if (field.dir === null && payload["create"] !== true) {
4741
- const sessionId = ctx.runtime.sessionId;
4742
- const bound = ctx.runtime.workspaces.of(sessionId);
4743
- return sendJson(res, 200, {
4744
- sessionId,
4745
- created: false,
4746
- workspace: bound ? toWireSessionWorkspace(bound) : null
4747
- });
4896
+ if (stat3.bytes > MAX_TEXT_BYTES) {
4897
+ return sendError(res, 413, "too-large", describeRefusal("too-large", lang));
4748
4898
  }
4749
- const role = typeof payload["role"] === "string" ? payload["role"].trim() : "";
4750
- const outcome = ctx.runtime.sessionFactory.create({
4751
- workspace: field.dir,
4752
- ...lang === void 0 ? {} : { lang },
4753
- reuse: payload["create"] !== true,
4754
- ...role === "" ? {} : { role }
4755
- });
4756
- if (!outcome.ok) {
4757
- if (outcome.reason === "unavailable") {
4758
- const mapped2 = unavailableToHttp(lang);
4759
- return sendError(res, mapped2.status, mapped2.code, mapped2.message);
4760
- }
4761
- if (outcome.reason === "unknown-role") {
4762
- const mapped2 = unknownRoleToHttp(outcome.detail, lang);
4763
- return sendError(res, mapped2.status, mapped2.code, mapped2.message);
4764
- }
4765
- const mapped = bindFailureToHttp(outcome, lang);
4766
- return sendError(res, mapped.status, mapped.code, mapped.message);
4899
+ const out = ctx.runtime.workspaceFiles.readFile(stat3.rel, root);
4900
+ if (!out.ok) {
4901
+ const refusal = out.reason === "binary" ? "unreadable" : out.reason;
4902
+ return sendError(res, statusFor(refusal), refusal, describeRefusal(refusal, lang));
4767
4903
  }
4768
- ctx.hub.register(outcome.live.session);
4769
- if (defer) ctx.runtime.workspaces.defer(outcome.live.sessionId);
4770
- if (outcome.created) ctx.hub.notifySessionsChanged(outcome.live.sessionId);
4771
4904
  sendJson(res, 200, {
4772
- sessionId: outcome.live.sessionId,
4773
- created: outcome.created,
4774
- workspace: outcome.live.workspace ? toWireSessionWorkspace(outcome.live.workspace) : null
4905
+ path,
4906
+ text: out.text,
4907
+ bytes: out.bytes
4775
4908
  });
4776
4909
  }
4777
- function sessionWorkspace(ctx, res, sessionId, lang) {
4778
- if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
4779
- return sendUnknownSession(res, sessionId);
4910
+ function readFileBytes(ctx, res, sessionId, rawUrl, headOnly, lang) {
4911
+ const root = resolveViewRoot(ctx, res, sessionId, lang);
4912
+ if (root === null) return;
4913
+ const path = singlePath(res, rawUrl, lang);
4914
+ if (path === null) return;
4915
+ const stat3 = ctx.runtime.workspaceFiles.statFile(path, root);
4916
+ if (!stat3.ok) {
4917
+ const refusal = toWireRefusal(stat3.reason);
4918
+ return sendError(res, statusFor(refusal), refusal, describeRefusal(refusal, lang));
4780
4919
  }
4781
- const state = ctx.runtime.workspaces.stateOf(sessionId);
4782
- if (state !== "none") {
4783
- return sendJson(res, 200, {
4784
- binding: toWireBinding(state, ctx.runtime.workspaces.of(sessionId))
4920
+ const known = VIEWABLE.get(extname(stat3.rel).toLowerCase());
4921
+ const inline = known !== void 0 && stat3.kind === "binary";
4922
+ const headers = {
4923
+ "Content-Type": inline ? known.mime : "application/octet-stream",
4924
+ "X-Content-Type-Options": "nosniff",
4925
+ "Content-Disposition": inline ? "inline" : `attachment; filename*=UTF-8''${encodeURIComponent(basenameOf(stat3.rel))}`
4926
+ };
4927
+ if (inline && known.kind === "pdf") headers["Content-Security-Policy"] = "sandbox";
4928
+ if (headOnly) {
4929
+ return sendEmpty(res, 200, {
4930
+ ...headers,
4931
+ "Content-Length": String(stat3.bytes)
4785
4932
  });
4786
4933
  }
4787
- if (ctx.hub.has(sessionId) || ctx.hub.isCooled(sessionId)) {
4788
- return sendJson(res, 200, {
4789
- binding: { state: "none" }
4790
- });
4934
+ sendFile(res, stat3.absPath, headers);
4935
+ }
4936
+ function basenameOf(rel) {
4937
+ const cut = rel.lastIndexOf("/");
4938
+ return cut < 0 ? rel : rel.slice(cut + 1);
4939
+ }
4940
+ // src/workspace/file-open.ts
4941
+ function describeOpenRefusal(refusal, lang) {
4942
+ if (refusal === "extension-not-allowed") {
4943
+ return t("web.file_open_extension_not_allowed", void 0, lang);
4791
4944
  }
4792
- sendError(res, 409, "not-live-session", t("web.workspace_not_live", void 0, lang));
4945
+ if (refusal === "not-same-machine") return t("web.file_open_not_same_machine", void 0, lang);
4946
+ if (refusal === "launch-failed") return t("web.file_open_launch_failed", void 0, lang);
4947
+ return describeRefusal(refusal, lang);
4793
4948
  }
4794
- async function bindWorkspace(ctx, req, res, sessionId, lang) {
4795
- if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
4796
- return sendUnknownSession(res, sessionId);
4949
+ function refuse(res, refusal, lang) {
4950
+ sendJson(res, 200, {
4951
+ ok: false,
4952
+ refusal,
4953
+ message: describeOpenRefusal(refusal, lang)
4954
+ });
4955
+ }
4956
+ async function openFileWithSystemApp(ctx, req, res, sessionId, lang) {
4957
+ if (!ctx.sameMachine) {
4958
+ return sendError(res, 403, "not-same-machine", describeOpenRefusal("not-same-machine", lang));
4797
4959
  }
4960
+ const root = resolveViewRoot(ctx, res, sessionId, lang);
4961
+ if (root === null) return;
4798
4962
  const body = await readJsonBody(req);
4799
4963
  if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
4800
- const parsed = readBindWorkspaceBody(asRecord(body.value), lang);
4801
- if (!parsed.ok) return sendError(res, 400, "bad-workspace", parsed.message);
4802
- const state = ctx.runtime.workspaces.stateOf(sessionId);
4803
- if (state !== "unbound") {
4804
- return sendError(
4805
- res,
4806
- 409,
4807
- "workspace-locked",
4808
- t("web.workspace_already_decided", void 0, lang)
4809
- );
4964
+ const payload = asRecord(body.value);
4965
+ const path = typeof payload["path"] === "string" ? payload["path"].trim() : "";
4966
+ if (path === "") {
4967
+ return sendError(res, 400, "missing-path", t("web.file_view_missing_path", void 0, lang));
4810
4968
  }
4811
- const turn = ctx.hub.stateOf(sessionId);
4812
- if (turn !== "idle") {
4813
- return sendError(res, 409, "busy", t("web.workspace_turn_running", void 0, lang), {
4814
- "X-Epoch-Turn-State": turn
4969
+ const plan = ctx.runtime.workspaceFiles.planSystemOpen(path, root);
4970
+ if (!plan.ok) return refuse(res, plan.reason, lang);
4971
+ try {
4972
+ const child = spawn(plan.command, [...plan.args], { stdio: "ignore", detached: true });
4973
+ await new Promise((done, fail) => {
4974
+ child.on("error", fail);
4975
+ child.on("spawn", () => {
4976
+ child.unref();
4977
+ done();
4978
+ });
4815
4979
  });
4980
+ } catch {
4981
+ return refuse(res, "launch-failed", lang);
4816
4982
  }
4817
- if (parsed.kind === "none") {
4818
- ctx.runtime.workspaces.decideNone(sessionId);
4819
- return sendJson(res, 200, {
4820
- binding: { state: "none" }
4821
- });
4983
+ sendJson(res, 200, { ok: true });
4984
+ }
4985
+ async function forgetWorkspace(ctx, sessionId, req, res, lang) {
4986
+ const body = await readJsonBody(req, void 0, lang);
4987
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
4988
+ if (!conformsToWire(body.value, WIRE_FORGET_WORKSPACE_FIELDS)) {
4989
+ return sendError(res, 400, "bad-request", t("web.forget_bad_request", {}, lang));
4822
4990
  }
4823
- const outcome = ctx.runtime.workspaces.bind(sessionId, parsed.root, lang);
4824
- if (!outcome.ok) {
4825
- const mapped = bindFailureToHttp(outcome, lang);
4826
- return sendError(res, mapped.status, mapped.code, mapped.message);
4991
+ const { root } = body.value;
4992
+ const workspaces = ctx.runtime.workspaces;
4993
+ if (!workspaces.known().some((w) => w.root === root)) {
4994
+ return sendError(res, 404, "unknown-workspace", t("web.forget_unknown", {}, lang));
4995
+ }
4996
+ const current = workspaces.of(sessionId)?.workspace.root;
4997
+ if (current !== void 0 && current === root) {
4998
+ return sendError(res, 409, "workspace-in-use", t("web.forget_in_use", {}, lang));
4827
4999
  }
5000
+ workspaces.forget(root);
4828
5001
  sendJson(res, 200, {
4829
- binding: { state: "bound", workspace: toWireSessionWorkspace(outcome.bound) }
5002
+ known: toWireTrustList(workspaces.known(), workspaces, current ?? null)
4830
5003
  });
4831
5004
  }
4832
- async function sessionDiff(ctx, res, sessionId, _lang) {
4833
- if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
4834
- return sendUnknownSession(res, sessionId);
4835
- }
4836
- const bound = ctx.runtime.workspaces.of(sessionId);
4837
- if (!bound) {
4838
- return sendJson(res, 200, {
4839
- source: "none",
4840
- root: "",
4841
- files: [],
4842
- truncated: false,
4843
- notes: ["no-workspace"]
4844
- });
4845
- }
4846
- sendJson(
4847
- res,
4848
- 200,
4849
- await collectWorkspaceDiff({
4850
- workDir: bound.workspace.root,
4851
- checkpoints: checkpointsOf(ctx, sessionId)
4852
- })
4853
- );
4854
- }
4855
5005
  function isKnownRoot(ctx, sessionId, root) {
4856
5006
  if (ctx.runtime.workspaces.of(sessionId)?.workspace.root === root) return true;
4857
5007
  return ctx.runtime.workspaces.known().some((w) => w.root === root);
@@ -4951,6 +5101,15 @@ async function handleApi(opts, req, res, method, segments, lang) {
4951
5101
  if (resource === "last-session" && first === void 0 && method === "PUT") {
4952
5102
  return putLastSession(ctx, req, res, lang);
4953
5103
  }
5104
+ if (resource === "host" && first === "compose" && second === void 0 && method === "POST") {
5105
+ return postHostCompose(ctx, req, res, lang);
5106
+ }
5107
+ if (resource === "host" && first === "compose" && second === void 0 && isRead(method)) {
5108
+ return getHostCompose(ctx, res);
5109
+ }
5110
+ if (resource === "host" && first === "compose" && second !== void 0 && detail === "take" && method === "POST") {
5111
+ return takeHostCompose(ctx, res, second);
5112
+ }
4954
5113
  if (resource === "sessions") {
4955
5114
  return handleSessions(opts, req, res, method, first, second, detail, lang);
4956
5115
  }
@@ -5330,6 +5489,7 @@ function buildHandler(opts, hub, binding, port, webRoot) {
5330
5489
  return createRequestHandler({
5331
5490
  ctx: {
5332
5491
  hub,
5492
+ hostCompose: new HostComposeStore(),
5333
5493
  runtime: opts.runtime,
5334
5494
  version: opts.version,
5335
5495
  artifactsRoot: opts.artifactsRoot ?? opts.runtime.artifactsRoot,