@epoch-agent/server 0.17.0 → 0.19.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 {
@@ -597,6 +661,14 @@ var SessionHub = class {
597
661
  if (this.closed) return;
598
662
  this.publish(sessionId, { type: "sessions-changed" });
599
663
  }
664
+ notifyPluginsChanged() {
665
+ if (this.closed) return;
666
+ this.publish(void 0, { type: "plugins-changed" });
667
+ }
668
+ notifyHostCompose(sessionId, payload) {
669
+ if (this.closed) return;
670
+ this.publish(sessionId, { type: "host-compose", ...payload });
671
+ }
600
672
  shutdown() {
601
673
  if (this.closed) return;
602
674
  this.closed = true;
@@ -607,10 +679,15 @@ var SessionHub = class {
607
679
  this.counted.clear();
608
680
  }
609
681
  publish(sessionId, event) {
610
- const frame = { seq: ++this.seq, sessionId, ts: this.clock(), event };
682
+ const frame = {
683
+ seq: ++this.seq,
684
+ ...sessionId === void 0 ? {} : { sessionId },
685
+ ts: this.clock(),
686
+ event
687
+ };
611
688
  this.ring.push(frame);
612
689
  for (const sink of this.sinks) sink(frame);
613
- for (const sink of this.observers) sink(frame);
690
+ if (sessionId !== void 0) for (const sink of this.observers) sink(frame);
614
691
  return frame;
615
692
  }
616
693
  connectionFrame(event) {
@@ -1654,12 +1731,19 @@ function lastSessionField(ctx) {
1654
1731
  if (!ctx.hub.has(recorded) && !ctx.runtime.sessions?.get(recorded)) return {};
1655
1732
  return { lastSessionId: recorded };
1656
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
+ }
1657
1740
  function config(ctx, res, lang) {
1658
1741
  const { runtime } = ctx;
1659
1742
  const bound = runtime.workspaces.of(runtime.sessionId);
1660
1743
  sendJson(res, 200, {
1661
1744
  sessionId: runtime.sessionId,
1662
1745
  ...lastSessionField(ctx),
1746
+ ...hostComposeField(ctx),
1663
1747
  model: runtime.config.model,
1664
1748
  permission: runtime.config.permission,
1665
1749
  workspace: bound ? toWireSessionWorkspace(bound) : null,
@@ -2369,2467 +2453,2555 @@ function listBlockedGoals(ctx, res, _lang) {
2369
2453
  );
2370
2454
  sendJson(res, 200, { items });
2371
2455
  }
2372
- async function putLastSession(ctx, req, res, lang) {
2373
- const control = ctx.runtime.lastOpenSession;
2374
- if (!control) {
2375
- return sendError(
2376
- res,
2377
- 503,
2378
- "no-last-session-store",
2379
- t("web.last_session_unavailable", {}, lang)
2380
- );
2381
- }
2382
- const body = await readJsonBody(req, void 0, lang);
2383
- if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2384
- if (!conformsToWire(body.value, WIRE_LAST_SESSION_FIELDS)) {
2385
- return sendError(res, 400, "bad-request", t("web.last_session_bad_request", {}, lang));
2386
- }
2387
- const { sessionId } = body.value;
2388
- if (typeof sessionId !== "string" || sessionId.length === 0) {
2389
- return sendError(res, 400, "bad-request", t("web.last_session_bad_request", {}, lang));
2390
- }
2391
- if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
2392
- return sendUnknownSession(res, sessionId, lang);
2393
- }
2394
- const failure = control.set(sessionId);
2395
- if (failure) return sendError(res, 500, "write-failed", failure(lang));
2396
- sendJson(res, 200, { ok: true });
2397
- }
2398
- function readConfig(ctx, res, lang) {
2399
- if (ctx.lanExposed) return sendLanExposed(res, lang);
2400
- const outcome = ctx.runtime.mcp.readConfig();
2401
- if (!outcome.ok) {
2402
- return sendError(
2403
- res,
2404
- 409,
2405
- "mcp-config-unreadable",
2406
- 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})` })
2407
2472
  );
2408
- }
2409
- const { snapshot } = outcome;
2410
- sendJson(res, 200, {
2411
- configPath: snapshot.configPath,
2412
- text: snapshot.text,
2413
- exists: snapshot.exists,
2414
- revision: snapshot.revision,
2415
- issues: toWireIssues(snapshot.issues)
2416
2473
  });
2417
2474
  }
2418
- async function saveConfig(ctx, req, res, lang) {
2419
- if (ctx.lanExposed) return sendLanExposed(res, lang);
2420
- const body = await readJsonBody(req);
2421
- if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2422
- const payload = asRecord(body.value);
2423
- const text = payload["text"];
2424
- const revision = payload["revision"];
2425
- if (typeof text !== "string" || typeof revision !== "string") {
2426
- return sendError(
2427
- res,
2428
- 400,
2429
- "bad-mcp-config-body",
2430
- t("web.mcp_config_bad_body", void 0, lang)
2431
- );
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
+ });
2432
2515
  }
2433
- const outcome = ctx.runtime.mcp.writeConfig(text, revision, lang);
2434
- if (!outcome.ok) return sendError(res, ...saveFailure(outcome.reason, outcome.detail, lang));
2435
- sendJson(res, 200, {
2436
- configPath: outcome.configPath,
2437
- revision: outcome.revision,
2438
- issues: toWireIssues(outcome.issues)
2439
- });
2516
+ return changes;
2440
2517
  }
2441
- async function applyConfig(ctx, req, res, lang) {
2442
- if (ctx.lanExposed) return sendLanExposed(res, lang);
2443
- const body = await readJsonBody(req);
2444
- if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2445
- const revision = asRecord(body.value)["revision"];
2446
- if (typeof revision !== "string") {
2447
- return sendError(
2448
- res,
2449
- 400,
2450
- "bad-mcp-config-body",
2451
- t("web.mcp_config_bad_body", void 0, lang)
2452
- );
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);
2453
2529
  }
2454
- const outcome = await ctx.runtime.mcp.applyConfig(revision, lang);
2455
- if (!outcome.ok) return sendError(res, ...applyFailure(outcome.reason, outcome.detail, lang));
2456
- sendJson(res, 200, {
2457
- configPath: outcome.configPath,
2458
- revision: outcome.revision,
2459
- issues: toWireIssues(outcome.issues),
2460
- changes: outcome.changes.map((change) => ({
2461
- name: change.name,
2462
- action: change.action,
2463
- ...change.status === void 0 ? {} : { server: toWireMcp(change.status) },
2464
- ...change.reason === void 0 ? {} : { reason: change.reason }
2465
- }))
2466
- });
2530
+ return sizes;
2467
2531
  }
2468
- function sendLanExposed(res, lang) {
2469
- 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"));
2470
2536
  }
2471
- function saveFailure(reason, detail, lang) {
2472
- if (reason === "stale") {
2473
- 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));
2474
2571
  }
2475
- if (reason === "invalid-json") return [400, "mcp-config-invalid-json", detail];
2476
- return [409, "mcp-config-unwritable", t("web.mcp_config_unwritable", { detail }, lang)];
2572
+ return { source: "git", root, files, truncated, notes };
2477
2573
  }
2478
- function applyFailure(reason, detail, lang) {
2479
- if (reason === "unreadable") {
2480
- 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 });
2481
2588
  }
2482
- if (reason === "stale") {
2483
- 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 });
2484
2591
  }
2485
- if (reason === "invalid-json") return [400, "mcp-config-invalid-json", detail];
2486
- 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);
2487
2593
  }
2488
- function toWireIssues(issues) {
2489
- 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 };
2490
2601
  }
2491
- async function reconnectMcp(ctx, res, name, lang) {
2492
- const outcome = await ctx.runtime.mcp.reconnect(name);
2493
- if (!outcome.ok) {
2494
- if (outcome.reason === "unknown") {
2495
- return sendError(res, 404, "unknown-mcp-server", t("web.mcp_unknown_server", { name }, lang));
2496
- }
2497
- const status = {
2498
- ...outcome.status,
2499
- ...outcome.detail === void 0 ? {} : { lastError: outcome.detail }
2500
- };
2501
- return sendJson(res, 200, {
2502
- ok: false,
2503
- server: toWireMcp(status)
2504
- });
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 };
2505
2608
  }
2506
- sendJson(res, 200, {
2507
- ok: true,
2508
- server: toWireMcp(outcome.status)
2509
- });
2510
- }
2511
- async function addMcp(ctx, req, res, lang) {
2512
- if (ctx.lanExposed) {
2513
- return sendError(
2514
- res,
2515
- 403,
2516
- "mcp-add-lan-exposed",
2517
- 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
+ })
2518
2620
  );
2519
2621
  }
2520
- const body = await readJsonBody(req);
2521
- if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2522
- const payload = asRecord(body.value);
2523
- const name = typeof payload["name"] === "string" ? payload["name"].trim() : "";
2524
- const transport = payload["transport"];
2525
- if (transport !== "stdio" && transport !== "sse" && transport !== "http") {
2526
- return sendError(res, 400, "bad-mcp-config", t("web.mcp_add_bad_transport", void 0, lang));
2527
- }
2528
- const outcome = await ctx.runtime.mcp.add({
2529
- name,
2530
- transport,
2531
- ...typeof payload["command"] === "string" ? { command: payload["command"].trim() } : {},
2532
- ...Array.isArray(payload["args"]) ? { args: stringList(payload["args"]) } : {},
2533
- ...typeof payload["url"] === "string" ? { url: payload["url"].trim() } : {},
2534
- ...payload["env"] === void 0 ? {} : { env: stringMap(payload["env"]) }
2535
- });
2536
- if (!outcome.ok) {
2537
- return sendError(res, ...addFailure(outcome.reason, outcome.detail, name, lang));
2538
- }
2539
- sendJson(res, 200, {
2540
- server: toWireMcp(outcome.status),
2541
- configPath: outcome.configPath
2542
- });
2622
+ return { source: "checkpoints", root: opts.workDir, files, truncated, notes };
2543
2623
  }
2544
- function addFailure(reason, detail, name, lang) {
2545
- if (reason === "invalid-name") {
2546
- 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 {
2547
2636
  }
2548
- if (reason === "invalid-config") return [400, "bad-mcp-config", detail];
2549
- if (reason === "duplicate")
2550
- return [409, "mcp-name-taken", t("web.mcp_add_taken", { name }, lang)];
2551
- return [409, "mcp-config-unwritable", t("web.mcp_add_unwritable", { detail }, lang)];
2637
+ return [...paths].sort();
2552
2638
  }
2553
- function stringList(value) {
2554
- 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("/");
2555
2643
  }
2556
- function stringMap(value) {
2557
- const out = {};
2558
- for (const [k, v] of Object.entries(asRecord(value))) {
2559
- 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;
2560
2652
  }
2561
- return out;
2562
2653
  }
2563
- function toRef(raw) {
2564
- 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
+ };
2565
2671
  }
2566
- function toWireSelection(selection) {
2567
- 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;
2568
2676
  }
2569
- function sessionHasImages(session) {
2570
- const history = session?.getHistory() ?? [];
2571
- 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;
2572
2679
  }
2573
- function getModel(ctx, res, sessionId, _lang) {
2574
- const { runtime } = ctx;
2575
- const live = runtime.sessionFactory.get(sessionId);
2576
- const selection = live?.model.get();
2577
- sendJson(res, 200, {
2578
- selection: selection ? toWireSelection(selection) : null,
2579
- configured: (live ? live.model.configured() : runtime.config.model) || null
2580
- });
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;
2581
2684
  }
2582
- async function setModel(ctx, req, res, sessionId, lang) {
2583
- const live = ctx.runtime.sessionFactory.get(sessionId);
2584
- if (!live) return sendUnknownSession(res, sessionId);
2585
- 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) {
2586
2690
  const body = await readJsonBody(req);
2587
2691
  if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2588
- const input = readModelSetBody(asRecord(body.value));
2589
- if (!input.ok)
2590
- return sendError(res, 400, "bad-model", t("web.model_bad_request", void 0, lang));
2591
- const ref = input.model === null ? null : toRef(input.model);
2592
- if (ref === void 0) {
2593
- return sendError(res, 400, "bad-model", t("web.model_bad_request", void 0, lang));
2594
- }
2595
- const selectionCtx = {
2596
- hasImages: sessionHasImages(live.session),
2597
- ...input.usedTokens === void 0 ? {} : { usedTokens: input.usedTokens }
2598
- };
2599
- const result = ref === null ? control.reset(selectionCtx) : control.set(ref, selectionCtx);
2600
- const payload = result.ok ? {
2601
- ok: true,
2602
- selection: toWireSelection(result.selection),
2603
- contextLength: result.contextLength,
2604
- caveats: result.caveats
2605
- } : { ok: false, rejection: result.rejection };
2606
- 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
+ });
2607
2699
  }
2608
- function readModelSetBody(body) {
2609
- const { model, usedTokens } = body;
2610
- if (model !== null && (typeof model !== "string" || model.trim() === "")) return { ok: false };
2611
- 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);
2612
2747
  return {
2613
2748
  ok: true,
2614
- model: model === null ? null : model.trim(),
2615
- ...used === void 0 ? {} : { usedTokens: used }
2749
+ sessionId: made.live.sessionId,
2750
+ created: made.created,
2751
+ workspace: made.live.workspace ? toWireSessionWorkspace(made.live.workspace) : null
2616
2752
  };
2617
2753
  }
2618
- function planModeState(ctx, sessionId) {
2619
- if (!ctx.runtime.plan) return null;
2620
- const plan = ctx.runtime.sessionFactory.get(sessionId)?.plan;
2621
- if (!plan) return { active: false, from: null };
2622
- 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));
2623
2770
  }
2624
- async function setPlanMode(ctx, req, res, sessionId, lang) {
2625
- const live = ctx.runtime.sessionFactory.get(sessionId);
2626
- if (!live) return sendUnknownSession(res, sessionId);
2627
- const plan = live.plan;
2628
- if (!plan) {
2629
- 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);
2630
2774
  }
2631
2775
  const body = await readJsonBody(req);
2632
2776
  if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2633
- const action = asRecord(body.value)["action"];
2634
- 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") {
2635
2781
  return sendError(
2636
2782
  res,
2637
- 400,
2638
- "bad-action",
2639
- 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)
2640
2786
  );
2641
2787
  }
2642
- 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
+ }
2643
2805
  sendJson(res, 200, {
2644
- mode: { active: plan.active(), from: plan.from() },
2645
- changed
2806
+ binding: { state: "bound", workspace: toWireSessionWorkspace(outcome.bound) }
2646
2807
  });
2647
2808
  }
2648
- // src/permission.ts
2649
- var REFUSAL = {
2650
- "managed-bypass-disabled": "managed"
2651
- };
2652
- function blockedLevels(managed) {
2653
- 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
+ );
2654
2831
  }
2655
- function permissionState(ctx, sessionId) {
2656
- const perms = ctx.runtime.sessionFactory.get(sessionId)?.permissions ?? null;
2657
- return {
2658
- level: perms?.level() ?? ctx.runtime.config.permission,
2659
- editable: perms !== null,
2660
- blocked: perms ? blockedLevels(perms.managed()) : [],
2661
- planMode: planModeState(ctx, sessionId)
2662
- };
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
+ });
2663
2882
  }
2664
- function getPermission(ctx, res, sessionId, _lang) {
2665
- if (!ctx.hub.has(sessionId)) return sendUnknownSession(res, sessionId);
2666
- 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
+ });
2667
2888
  }
2668
- async function setPermission(ctx, req, res, sessionId, lang) {
2669
- const live = ctx.runtime.sessionFactory.get(sessionId);
2670
- if (!live) return sendUnknownSession(res, sessionId);
2671
- const perms = live.permissions;
2672
- 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) {
2673
2900
  return sendError(
2674
2901
  res,
2675
2902
  503,
2676
- "no-permission-layer",
2677
- 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)
2678
2932
  );
2679
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);
2680
2945
  const body = await readJsonBody(req);
2681
2946
  if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2682
- const level = asRecord(body.value)["level"];
2683
- 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") {
2684
2951
  return sendError(
2685
2952
  res,
2686
2953
  400,
2687
- "bad-level",
2688
- 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)
2689
2956
  );
2690
2957
  }
2691
- const before = perms.level();
2692
- const result = perms.setLevel(level, { remember: true });
2693
- const state = permissionState(ctx, sessionId);
2694
- const payload = result.ok ? {
2695
- ok: true,
2696
- changed: state.level !== before,
2697
- state,
2698
- ...result.notRemembered ? { notRemembered: result.notRemembered } : {}
2699
- } : { ok: false, refused: REFUSAL[result.reason], changed: false, state };
2700
- 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
+ });
2701
2965
  }
2702
- // src/plan.ts
2703
- function getPlan(ctx, res, sessionId, _lang) {
2704
- if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
2705
- 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
+ );
2706
2978
  }
2707
- const live = ctx.runtime.sessionFactory.get(sessionId);
2708
- 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));
2709
2981
  sendJson(res, 200, {
2710
- plan: normalize(markdown),
2711
- 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
+ }))
2712
2991
  });
2713
2992
  }
2714
- function normalize(markdown) {
2715
- 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));
2716
2995
  }
2717
- function toWireCounts(counts) {
2718
- return {
2719
- commands: counts.commands,
2720
- roles: counts.roles,
2721
- skills: counts.skills,
2722
- hooks: counts.hooks,
2723
- denyRules: counts.denyRules,
2724
- mcpServers: counts.mcpServers
2725
- };
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)];
2726
3002
  }
2727
- function displayNamesOf(control) {
2728
- const out = /* @__PURE__ */ new Map();
2729
- for (const hit of control.search()) {
2730
- const shown = hit.entry.displayName;
2731
- 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)];
2732
3006
  }
2733
- 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)];
2734
3012
  }
2735
- function toWirePluginEntry(entry, displayNames) {
2736
- return {
2737
- name: entry.name,
2738
- displayName: displayNames.get(`${entry.marketplace}/${entry.name}`) ?? null,
2739
- version: entry.version,
2740
- source: entry.source,
2741
- sourceType: entry.sourceType,
2742
- path: entry.path,
2743
- linked: entry.linked,
2744
- enabled: entry.enabled,
2745
- installedAt: entry.installedAt,
2746
- marketplace: entry.marketplace ?? null,
2747
- active: entry.active,
2748
- counts: entry.counts ? toWireCounts(entry.counts) : null
2749
- };
3013
+ function toWireIssues(issues) {
3014
+ return issues.map((issue) => ({ path: issue.path, message: issue.message }));
2750
3015
  }
2751
- function toWirePluginHit(hit, installed) {
2752
- const mine = installed.get(hit.entry.name);
2753
- return {
2754
- marketplace: hit.marketplace,
2755
- ref: hit.ref,
2756
- name: hit.entry.name,
2757
- description: hit.entry.description ?? "",
2758
- category: hit.entry.category ?? null,
2759
- keywords: [...hit.entry.keywords ?? []],
2760
- sourceType: hit.sourceType ?? null,
2761
- installable: hit.installable,
2762
- installed: mine !== void 0,
2763
- author: hit.entry.author?.name ?? null,
2764
- downloads: hit.entry.downloads ?? null,
2765
- likes: hit.entry.likes ?? null,
2766
- displayName: hit.entry.displayName ?? null,
2767
- icon: hit.entry.icon ?? null,
2768
- version: hit.entry.version ?? null,
2769
- changelog: hit.entry.changelog ?? null,
2770
- size: hit.entry.size ?? null,
2771
- homepage: hit.entry.homepage ?? null,
2772
- visibility: hit.entry.visibility ?? null,
2773
- status: hit.entry.status ?? null,
2774
- updateAvailable: mine !== void 0 && !mine.linked && hit.entry.version !== void 0 && hit.entry.version !== mine.version
2775
- };
2776
- }
2777
- function toWireMarketplace(market) {
2778
- return {
2779
- name: market.name,
2780
- source: market.source,
2781
- entries: market.entries,
2782
- addedAt: market.addedAt,
2783
- local: market.local,
2784
- categories: (market.categories ?? []).map(toWireCategory)
2785
- };
2786
- }
2787
- function toWireCategory(node) {
2788
- return {
2789
- id: node.id,
2790
- label: node.label ?? node.id,
2791
- children: (node.children ?? []).map(toWireCategory)
2792
- };
2793
- }
2794
- function toWirePreview2(preview) {
2795
- const { manifest, source, inventory } = preview;
2796
- return {
2797
- name: manifest.name,
2798
- version: manifest.version,
2799
- description: manifest.description ?? "",
2800
- source: source.raw,
2801
- sourceType: source.type,
2802
- checksum: source.sha256 ?? null,
2803
- author: manifest.author?.name ?? null,
2804
- homepage: manifest.homepage ?? null,
2805
- commands: [...inventory.commands],
2806
- roles: [...inventory.roles],
2807
- skills: [...inventory.skills],
2808
- hooks: inventory.hooks.map((h) => ({ type: h.type, count: h.count })),
2809
- denyRules: inventory.denyRules,
2810
- mcpServers: [...inventory.mcpServers],
2811
- ignoredBuckets: [...inventory.ignoredBuckets],
2812
- hasJsTools: inventory.jsTools,
2813
- conflict: preview.conflict ? { name: preview.conflict.name, version: preview.conflict.version } : null
2814
- };
2815
- }
2816
- function controlOf(ctx, res, lang) {
2817
- const control = ctx.runtime.plugins;
2818
- if (!control) {
2819
- sendError(res, 503, "no-plugin-control", t("web.plugin_not_available", void 0, lang));
2820
- return null;
2821
- }
2822
- return control;
2823
- }
2824
- function refusedByLan(ctx, res, lang) {
2825
- if (!ctx.lanExposed) return false;
2826
- sendError(
2827
- res,
2828
- 403,
2829
- "plugin-write-lan-exposed",
2830
- t("web.plugin_write_lan_exposed", void 0, lang)
2831
- );
2832
- return true;
2833
- }
2834
- function fieldOf(payload, key) {
2835
- const value = payload[key];
2836
- return typeof value === "string" ? value.trim() : "";
2837
- }
2838
- function uploadFormOf(payload) {
2839
- const form = {};
2840
- for (const name of WIRE_PLUGIN_UPLOAD_FORM_FIELDS) {
2841
- const raw = fieldOf(payload, name);
2842
- if (raw === "") continue;
2843
- if (name === "visibility") {
2844
- if (raw === "private" || raw === "public") form[name] = raw;
2845
- continue;
2846
- }
2847
- form[name] = raw;
2848
- }
2849
- return form;
2850
- }
2851
- function listPlugins(ctx, res, lang) {
2852
- const control = controlOf(ctx, res, lang);
2853
- if (!control) return;
2854
- sendJson(res, 200, pluginsPayload(control));
2855
- }
2856
- function pluginsPayload(control) {
2857
- const installed = control.list();
2858
- const installedMap = new Map(
2859
- installed.map((p) => [p.name, { version: p.version, linked: p.linked }])
2860
- );
2861
- const shown = displayNamesOf(control);
2862
- return {
2863
- installed: installed.map((p) => toWirePluginEntry(p, shown)),
2864
- hits: control.search().map((hit) => toWirePluginHit(hit, installedMap)),
2865
- marketplaces: control.marketplaces().map(toWireMarketplace),
2866
- pendingRestart: control.pendingRestart,
2867
- uploadEnabled: control.uploadEnabled,
2868
- uploadOptions: control.uploadOptions,
2869
- refreshEnabled: control.refreshEnabled
2870
- };
2871
- }
2872
- function sendPreview(res, outcome) {
3016
+ async function reconnectMcp(ctx, res, name, lang) {
3017
+ const outcome = await ctx.runtime.mcp.reconnect(name);
2873
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
+ };
2874
3026
  return sendJson(res, 200, {
2875
3027
  ok: false,
2876
- preview: null,
2877
- token: null,
2878
- reason: outcome.reason,
2879
- detail: outcome.detail
3028
+ server: toWireMcp(status)
2880
3029
  });
2881
3030
  }
2882
3031
  sendJson(res, 200, {
2883
3032
  ok: true,
2884
- preview: toWirePreview2(outcome.preview),
2885
- token: outcome.token,
2886
- reason: null,
2887
- detail: null
3033
+ server: toWireMcp(outcome.status)
2888
3034
  });
2889
3035
  }
2890
- async function previewPluginInstall(ctx, req, res, lang) {
2891
- if (refusedByLan(ctx, res, lang)) return;
2892
- const control = controlOf(ctx, res, lang);
2893
- if (!control) return;
2894
- const body = await readJsonBody(req);
2895
- if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2896
- sendPreview(res, await control.preview(fieldOf(asRecord(body.value), "ref"), lang));
2897
- }
2898
- async function previewLocalPluginInstall(ctx, req, res, lang) {
2899
- if (refusedByLan(ctx, res, lang)) return;
2900
- const control = controlOf(ctx, res, lang);
2901
- if (!control) return;
2902
- const body = await readJsonBody(req);
2903
- if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2904
- sendPreview(res, await control.previewLocal(fieldOf(asRecord(body.value), "path"), lang));
2905
- }
2906
- async function previewPluginUpdate(ctx, req, res, lang) {
2907
- if (refusedByLan(ctx, res, lang)) return;
2908
- const control = controlOf(ctx, res, lang);
2909
- 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
+ }
2910
3045
  const body = await readJsonBody(req);
2911
3046
  if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2912
- sendPreview(res, await control.previewUpdate(fieldOf(asRecord(body.value), "name"), lang));
2913
- }
2914
- function sendAction(res, control, outcome) {
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
+ });
2915
3061
  if (!outcome.ok) {
2916
- return sendJson(res, 200, {
2917
- ok: false,
2918
- entry: null,
2919
- pendingRestart: control.pendingRestart,
2920
- detail: outcome.detail,
2921
- reason: outcome.reason
2922
- });
3062
+ return sendError(res, ...addFailure(outcome.reason, outcome.detail, name, lang));
2923
3063
  }
2924
- const fresh = "record" in outcome ? control.list().find((p) => p.name === outcome.record.name) : void 0;
2925
3064
  sendJson(res, 200, {
2926
- ok: true,
2927
- entry: fresh ? toWirePluginEntry(fresh, displayNamesOf(control)) : null,
2928
- pendingRestart: control.pendingRestart,
2929
- detail: "detail" in outcome ? outcome.detail : null,
2930
- reason: null
3065
+ server: toWireMcp(outcome.status),
3066
+ configPath: outcome.configPath
2931
3067
  });
2932
3068
  }
2933
- async function installPluginFromMarket(ctx, req, res, lang) {
2934
- if (refusedByLan(ctx, res, lang)) return;
2935
- const control = controlOf(ctx, res, lang);
2936
- if (!control) return;
2937
- const body = await readJsonBody(req);
2938
- if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2939
- const payload = asRecord(body.value);
2940
- sendAction(
2941
- res,
2942
- control,
2943
- await control.install(fieldOf(payload, "ref"), fieldOf(payload, "token"), lang)
2944
- );
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)];
2945
3077
  }
2946
- async function installLocalPlugin(ctx, req, res, lang) {
2947
- if (refusedByLan(ctx, res, lang)) return;
2948
- const control = controlOf(ctx, res, lang);
2949
- if (!control) return;
2950
- const body = await readJsonBody(req);
2951
- if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2952
- const payload = asRecord(body.value);
2953
- sendAction(
2954
- res,
2955
- control,
2956
- await control.installLocal(fieldOf(payload, "path"), fieldOf(payload, "token"), lang)
2957
- );
3078
+ function stringList(value) {
3079
+ return value.filter((v) => typeof v === "string");
2958
3080
  }
2959
- async function stagePlugin(ctx, req, res, lang) {
2960
- if (refusedByLan(ctx, res, lang)) return;
2961
- const control = controlOf(ctx, res, lang);
2962
- if (!control) return;
2963
- const asJson = (req.headers["content-type"] ?? "").toLowerCase().includes("application/json");
2964
- if (asJson) {
2965
- const body = await readJsonBody(req, void 0, lang);
2966
- if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2967
- return sendStage(
2968
- res,
2969
- await control.stage({ kind: "path", path: fieldOf(asRecord(body.value), "path") }, lang)
2970
- );
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;
2971
3085
  }
2972
- const raw = await readRawBody(req, PLUGIN_UPLOAD_BODY_LIMIT, lang);
2973
- if (!raw.ok) return sendError(res, raw.status, "bad-body", raw.message);
2974
- return sendStage(
2975
- res,
2976
- await control.stage({ kind: "bytes", name: nameParam2(req), data: raw.value }, lang)
2977
- );
3086
+ return out;
2978
3087
  }
2979
- function nameParam2(req) {
2980
- try {
2981
- return new URL(req.url ?? "/", "http://127.0.0.1").searchParams.get("name") ?? "";
2982
- } catch {
2983
- return "";
2984
- }
3088
+ function toRef(raw) {
3089
+ return parseModelRef(raw);
2985
3090
  }
2986
- function sendStage(res, outcome) {
2987
- const payload = outcome.ok ? {
2988
- ok: true,
2989
- path: outcome.path,
2990
- name: outcome.name,
2991
- version: outcome.version,
2992
- detail: null,
2993
- reason: null
2994
- } : {
2995
- ok: false,
2996
- path: null,
2997
- name: null,
2998
- version: null,
2999
- detail: outcome.detail,
3000
- reason: outcome.reason
3001
- };
3002
- sendJson(res, 200, payload);
3003
- }
3004
- async function updateInstalledPlugin(ctx, req, res, lang) {
3005
- if (refusedByLan(ctx, res, lang)) return;
3006
- const control = controlOf(ctx, res, lang);
3007
- if (!control) return;
3008
- const body = await readJsonBody(req);
3009
- if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3010
- const payload = asRecord(body.value);
3011
- sendAction(
3012
- res,
3013
- control,
3014
- await control.update(fieldOf(payload, "name"), fieldOf(payload, "token"), lang)
3015
- );
3091
+ function toWireSelection(selection) {
3092
+ return { provider: selection.provider, model: selection.model, origin: selection.origin };
3016
3093
  }
3017
- async function uninstallPluginByName(ctx, req, res, lang) {
3018
- if (refusedByLan(ctx, res, lang)) return;
3019
- const control = controlOf(ctx, res, lang);
3020
- if (!control) return;
3021
- const body = await readJsonBody(req);
3022
- if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3023
- sendAction(res, control, await control.uninstall(fieldOf(asRecord(body.value), "name"), lang));
3094
+ function sessionHasImages(session) {
3095
+ const history = session?.getHistory() ?? [];
3096
+ return history.some((m) => m.parts?.some((p) => p.type === "image"));
3024
3097
  }
3025
- function sendMarket(res, outcome) {
3098
+ function getModel(ctx, res, sessionId, _lang) {
3099
+ const { runtime } = ctx;
3100
+ const live = runtime.sessionFactory.get(sessionId);
3101
+ const selection = live?.model.get();
3026
3102
  sendJson(res, 200, {
3027
- ok: outcome.ok,
3028
- marketplace: outcome.ok && outcome.marketplace ? toWireMarketplace(outcome.marketplace) : null,
3029
- reason: outcome.ok ? null : outcome.reason,
3030
- detail: outcome.ok ? null : outcome.detail
3031
- });
3032
- }
3033
- async function addPluginMarketplace(ctx, req, res, lang) {
3034
- if (refusedByLan(ctx, res, lang)) return;
3035
- const control = controlOf(ctx, res, lang);
3036
- if (!control) return;
3037
- const body = await readJsonBody(req);
3038
- if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3039
- sendMarket(res, await control.addMarketplace(fieldOf(asRecord(body.value), "source"), lang));
3040
- }
3041
- async function removePluginMarketplace(ctx, req, res, lang) {
3042
- if (refusedByLan(ctx, res, lang)) return;
3043
- const control = controlOf(ctx, res, lang);
3044
- if (!control) return;
3045
- const body = await readJsonBody(req);
3046
- if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3047
- sendMarket(res, await control.removeMarketplace(fieldOf(asRecord(body.value), "name"), lang));
3048
- }
3049
- function previewPluginUpload(ctx, req, res, lang) {
3050
- return withUploadBody(ctx, req, res, lang, (control, payload) => {
3051
- sendUploadPreview(res, control.previewUpload(fieldOf(payload, "path"), lang));
3052
- });
3053
- }
3054
- function uploadPlugin(ctx, req, res, lang) {
3055
- return withUploadBody(ctx, req, res, lang, async (control, payload) => {
3056
- sendUpload(
3057
- res,
3058
- await control.upload(
3059
- fieldOf(payload, "path"),
3060
- fieldOf(payload, "token"),
3061
- uploadFormOf(payload),
3062
- lang
3063
- )
3064
- );
3103
+ selection: selection ? toWireSelection(selection) : null,
3104
+ configured: (live ? live.model.configured() : runtime.config.model) || null
3065
3105
  });
3066
3106
  }
3067
- async function withUploadBody(ctx, req, res, lang, run) {
3068
- if (refusedByLan(ctx, res, lang)) return;
3069
- const control = controlOf(ctx, res, lang);
3070
- 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;
3071
3111
  const body = await readJsonBody(req);
3072
3112
  if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3073
- await run(control, asRecord(body.value));
3074
- }
3075
- function sendUploadPreview(res, outcome) {
3076
- if (!outcome.ok) {
3077
- return sendJson(res, 200, {
3078
- ok: false,
3079
- plan: null,
3080
- token: null,
3081
- prefill: null,
3082
- fromManifest: null,
3083
- reason: outcome.reason,
3084
- detail: outcome.detail
3085
- });
3086
- }
3087
- const { manifest, files, totalBytes, suspicious, skippedLinks } = outcome.plan;
3088
- sendJson(res, 200, {
3089
- ok: true,
3090
- plan: {
3091
- name: manifest.name,
3092
- version: manifest.version,
3093
- description: manifest.description ?? "",
3094
- author: manifest.author?.name ?? null,
3095
- files: files.map((f) => ({ path: f.path, bytes: f.bytes })),
3096
- totalBytes,
3097
- suspicious: [...suspicious],
3098
- skippedLinks: [...skippedLinks]
3099
- },
3100
- token: outcome.token,
3101
- prefill: outcome.prefill,
3102
- fromManifest: [...outcome.fromManifest],
3103
- reason: null,
3104
- detail: null
3105
- });
3106
- }
3107
- function sendUpload(res, outcome) {
3108
- sendJson(res, 200, {
3109
- ok: outcome.ok,
3110
- detail: outcome.detail,
3111
- uploaded: outcome.ok ? { name: outcome.name, version: outcome.version, bytes: outcome.bytes } : null,
3112
- reason: outcome.ok ? null : outcome.reason
3113
- });
3114
- }
3115
- async function refreshPlugins(ctx, res, lang) {
3116
- if (refusedByLan(ctx, res, lang)) return;
3117
- const control = controlOf(ctx, res, lang);
3118
- if (!control) return;
3119
- const outcome = await control.refresh(lang);
3120
- if (!outcome.ok) {
3121
- return sendJson(res, 200, {
3122
- ok: false,
3123
- plugins: null,
3124
- reason: outcome.reason,
3125
- detail: outcome.detail
3126
- });
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));
3127
3119
  }
3128
- sendJson(res, 200, {
3129
- ok: true,
3130
- plugins: pluginsPayload(control),
3131
- reason: null,
3132
- detail: outcome.detail === "" ? null : outcome.detail
3133
- });
3134
- }
3135
- function toWireProvider(option, lang) {
3136
- return {
3137
- type: option.type,
3138
- label: providerLabel(option, lang),
3139
- envVar: option.envVar,
3140
- hasKey: option.hasKey,
3141
- keyHint: option.keyHint,
3142
- current: option.current
3120
+ const selectionCtx = {
3121
+ hasImages: sessionHasImages(live.session),
3122
+ ...input.usedTokens === void 0 ? {} : { usedTokens: input.usedTokens }
3143
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);
3144
3132
  }
3145
- function toWireSuggestions(view) {
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;
3146
3137
  return {
3147
- provider: view.provider,
3148
- models: view.models,
3149
- source: view.source,
3150
- status: view.status
3138
+ ok: true,
3139
+ model: model === null ? null : model.trim(),
3140
+ ...used === void 0 ? {} : { usedTokens: used }
3151
3141
  };
3152
3142
  }
3153
- function listProviders(ctx, res, lang) {
3154
- sendJson(res, 200, {
3155
- providers: ctx.runtime.modelCatalog.providers().map((o) => toWireProvider(o, lang))
3156
- });
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() };
3157
3148
  }
3158
- async function discoverProviderModels(ctx, req, res, type, lang) {
3159
- if (!isProviderType(type)) {
3160
- return sendError(res, 404, "unknown-provider", t("web.provider_unknown", { name: type }, lang));
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));
3161
3155
  }
3162
3156
  const body = await readJsonBody(req);
3163
3157
  if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3164
- const refresh = asRecord(body.value).refresh === true;
3165
- const suggestions = await ctx.runtime.modelCatalog.discover(type, { refresh });
3166
- sendJson(res, 200, {
3167
- suggestions: toWireSuggestions(suggestions)
3168
- });
3169
- }
3170
- async function setProviderKey(ctx, req, res, type, lang) {
3171
- if (!isProviderType(type)) {
3172
- return sendError(res, 404, "unknown-provider", t("web.provider_unknown", { name: type }, lang));
3173
- }
3174
- const envVar = apiKeyEnvVar(type);
3175
- if (envVar === null) {
3158
+ const action = asRecord(body.value)["action"];
3159
+ if (typeof action !== "string" || !isWirePlanAction(action)) {
3176
3160
  return sendError(
3177
3161
  res,
3178
3162
  400,
3179
- "key-not-applicable",
3180
- t("web.provider_key_not_applicable", { name: type }, lang)
3163
+ "bad-action",
3164
+ t("web.plan_mode_bad_action", { actions: WIRE_PLAN_ACTIONS.join("/") }, lang)
3181
3165
  );
3182
3166
  }
3183
- const body = await readJsonBody(req);
3184
- if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3185
- const raw = asRecord(body.value)["apiKey"];
3186
- const apiKey = typeof raw === "string" ? raw.trim() : "";
3187
- if (apiKey === "") {
3188
- return sendError(res, 400, "empty-key", t("web.provider_key_empty", void 0, lang));
3189
- }
3190
- const written = await ctx.runtime.modelCatalog.setKey(type, apiKey);
3167
+ const changed = action === "enter" ? plan.enter().ok : plan.leave() !== null;
3191
3168
  sendJson(res, 200, {
3192
- provider: type,
3193
- hasKey: true,
3194
- keyHint: written.keyHint,
3195
- backend: written.backend,
3196
- encrypted: written.encrypted,
3197
- envPath: written.envPath
3169
+ mode: { active: plan.active(), from: plan.from() },
3170
+ changed
3198
3171
  });
3199
3172
  }
3200
- async function setDefaultModel(ctx, req, res, lang) {
3201
- if (ctx.runtime.providerInfo !== null) {
3202
- return sendError(
3203
- res,
3204
- 409,
3205
- "provider-available",
3206
- t("web.model_default_has_provider", void 0, lang)
3207
- );
3208
- }
3209
- const body = await readJsonBody(req, void 0, lang);
3210
- if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3211
- const raw = asRecord(body.value)["model"];
3212
- const model = typeof raw === "string" ? raw.trim() : "";
3213
- if (model === "") {
3214
- return sendError(res, 400, "bad-request", t("web.model_default_empty", void 0, lang));
3215
- }
3216
- const report = ctx.runtime.settings.write({ key: "model", layer: "user", value: model });
3217
- if (!report.ok) {
3218
- const failure = settingWriteFailure(report);
3219
- return sendError(
3220
- res,
3221
- failure.status,
3222
- failure.code,
3223
- t(failure.key, { path: failure.path }, lang)
3224
- );
3225
- }
3226
- sendJson(res, 200, toWireSettingsWrite(report));
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" }] : [];
3227
3179
  }
3228
- async function addRole(ctx, req, res, lang) {
3229
- if (ctx.lanExposed) {
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
+ };
3188
+ }
3189
+ function getPermission(ctx, res, sessionId, _lang) {
3190
+ if (!ctx.hub.has(sessionId)) return sendUnknownSession(res, sessionId);
3191
+ sendJson(res, 200, permissionState(ctx, sessionId));
3192
+ }
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) {
3230
3198
  return sendError(
3231
3199
  res,
3232
- 403,
3233
- "role-add-lan-exposed",
3234
- t("web.role_add_lan_exposed", void 0, lang)
3200
+ 503,
3201
+ "no-permission-layer",
3202
+ t("web.permission_unavailable", void 0, lang)
3235
3203
  );
3236
3204
  }
3237
3205
  const body = await readJsonBody(req);
3238
3206
  if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3239
- const payload = asRecord(body.value);
3240
- const outcome = ctx.runtime.roleWrite.add(
3241
- {
3242
- name: typeof payload["name"] === "string" ? payload["name"].trim() : "",
3243
- description: typeof payload["description"] === "string" ? payload["description"].trim() : "",
3244
- ...typeof payload["prompt"] === "string" ? { prompt: payload["prompt"] } : {},
3245
- ...Array.isArray(payload["tools"]) ? { tools: stringList2(payload["tools"]) } : {},
3246
- ...Array.isArray(payload["skills"]) ? { skills: stringList2(payload["skills"]) } : {},
3247
- ...typeof payload["maxTurns"] === "number" ? { maxTurns: payload["maxTurns"] } : {}
3248
- },
3249
- lang
3250
- );
3251
- if (!outcome.ok) return sendError(res, ...addFailure2(outcome.reason, outcome.detail, lang));
3252
- sendJson(res, 200, {
3253
- role: toWireRole(outcome.role, ctx.runtime.agentRoleNotices),
3254
- path: outcome.path
3255
- });
3256
- }
3257
- function addFailure2(reason, detail, lang) {
3258
- if (reason === "invalid-name") return [400, "bad-role-name", detail];
3259
- if (reason === "invalid-field")
3260
- return [400, "bad-role-field", t("web.role_add_bad_field", { detail }, lang)];
3261
- if (reason === "unknown-tool") return [400, "bad-role-tools", detail];
3262
- if (reason === "duplicate") return [409, "role-name-taken", detail];
3263
- return [409, "role-unwritable", detail];
3264
- }
3265
- function stringList2(value) {
3266
- return value.filter((v) => typeof v === "string");
3267
- }
3268
- async function removeRole(ctx, req, res, lang) {
3269
- if (ctx.lanExposed) {
3207
+ const level = asRecord(body.value)["level"];
3208
+ if (typeof level !== "string" || !isPermissionLevel(level)) {
3270
3209
  return sendError(
3271
3210
  res,
3272
- 403,
3273
- "role-remove-lan-exposed",
3274
- t("web.role_remove_lan_exposed", void 0, lang)
3211
+ 400,
3212
+ "bad-level",
3213
+ t("web.permission_bad_level", { levels: PERMISSION_LEVELS.join("/") }, lang)
3275
3214
  );
3276
3215
  }
3277
- const body = await readJsonBody(req);
3278
- if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3279
- const payload = asRecord(body.value);
3280
- const name = typeof payload["name"] === "string" ? payload["name"].trim() : "";
3281
- const outcome = ctx.runtime.roleWrite.remove(name, lang);
3282
- if (!outcome.ok) {
3283
- return sendJson(res, 200, {
3284
- ok: false,
3285
- name: "",
3286
- path: null,
3287
- takenOverBy: null,
3288
- detail: outcome.detail,
3289
- reason: outcome.reason
3290
- });
3216
+ const before = perms.level();
3217
+ const result = perms.setLevel(level, { remember: true });
3218
+ const state = permissionState(ctx, sessionId);
3219
+ const payload = result.ok ? {
3220
+ ok: true,
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);
3226
+ }
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);
3291
3231
  }
3232
+ const live = ctx.runtime.sessionFactory.get(sessionId);
3233
+ const markdown = live ? live.plan?.approvedPlan() ?? null : ctx.runtime.sessionStore?.loadPlan(sessionId) ?? null;
3292
3234
  sendJson(res, 200, {
3293
- ok: true,
3294
- name: outcome.name,
3295
- path: outcome.path,
3296
- takenOverBy: outcome.takenOverBy,
3297
- detail: null,
3298
- reason: null
3235
+ plan: normalize(markdown),
3236
+ mode: planModeState(ctx, sessionId)
3299
3237
  });
3300
3238
  }
3301
- function stringList3(value) {
3302
- if (!Array.isArray(value)) return null;
3303
- const out = [];
3304
- for (const item of value) {
3305
- if (typeof item !== "string" || item.trim().length === 0) return null;
3306
- out.push(item);
3307
- }
3308
- return out;
3239
+ function normalize(markdown) {
3240
+ return markdown?.trim() ? markdown : null;
3309
3241
  }
3310
- function operationList(value) {
3311
- if (!Array.isArray(value)) return null;
3312
- const out = [];
3313
- for (const item of value) {
3314
- if (typeof item !== "string" || !isOperationType(item)) return null;
3315
- out.push(item);
3242
+ function toWireCounts(counts) {
3243
+ return {
3244
+ commands: counts.commands,
3245
+ roles: counts.roles,
3246
+ skills: counts.skills,
3247
+ hooks: counts.hooks,
3248
+ denyRules: counts.denyRules,
3249
+ mcpServers: counts.mcpServers
3250
+ };
3251
+ }
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);
3316
3257
  }
3317
3258
  return out;
3318
3259
  }
3319
- function finite(value) {
3320
- return typeof value === "number" && Number.isFinite(value) ? value : null;
3260
+ function toWirePluginEntry(entry, displayNames) {
3261
+ return {
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
3276
+ };
3321
3277
  }
3322
- function parseTrigger(value) {
3323
- const raw = asRecord(value);
3324
- const kind = raw["kind"];
3325
- const at = raw["at"];
3326
- if (kind === "interval") {
3327
- const every = finite(raw["everyMinutes"]);
3328
- return every === null ? null : { kind: "interval", everyMinutes: every };
3329
- }
3330
- if (typeof at !== "string") return null;
3331
- if (kind === "once") {
3332
- const date = raw["date"];
3333
- return typeof date === "string" ? { kind: "once", date, at } : null;
3334
- }
3335
- if (kind !== "cron") return null;
3336
- const cycle = raw["cycle"];
3337
- if (cycle === "daily") return { kind: "cron", cycle: "daily", at };
3338
- if (cycle === "weekly") {
3339
- const days = weekdayList(raw["weekdays"]);
3340
- return days === null ? null : { kind: "cron", cycle: "weekly", at, weekdays: days };
3341
- }
3342
- if (cycle === "monthly") {
3343
- const days = numberList(raw["days"]);
3344
- return days === null ? null : { kind: "cron", cycle: "monthly", at, days };
3345
- }
3346
- return null;
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
+ category: 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
+ };
3347
3310
  }
3348
- function weekdayList(value) {
3349
- if (!Array.isArray(value)) return null;
3350
- const out = [];
3351
- for (const item of value) {
3352
- if (typeof item !== "number" || !Number.isInteger(item) || item < 0 || item > 6) return null;
3353
- out.push(item);
3354
- }
3355
- return out;
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
+ };
3356
3320
  }
3357
- function numberList(value) {
3358
- if (!Array.isArray(value)) return null;
3359
- const out = [];
3360
- for (const item of value) {
3361
- if (typeof item !== "number" || !Number.isInteger(item)) return null;
3362
- out.push(item);
3363
- }
3364
- return out;
3321
+ function toWireCategory(node) {
3322
+ return {
3323
+ id: node.id,
3324
+ label: node.label ?? node.id,
3325
+ children: (node.children ?? []).map(toWireCategory)
3326
+ };
3365
3327
  }
3366
- var CLEARABLE = ["model", "role", "workDir", "startDate", "endDate"];
3367
- function optionals(raw, allowClear, lang) {
3368
- const out = {};
3369
- const bad = (field) => ({
3370
- ok: false,
3371
- message: t("web.schedule_bad_field", { field }, lang)
3372
- });
3373
- for (const key of ["name", "prompt"]) {
3374
- const value = raw[key];
3375
- if (value === void 0) continue;
3376
- if (typeof value !== "string") return bad(key);
3377
- out[key] = value;
3378
- }
3379
- for (const key of CLEARABLE) {
3380
- const value = raw[key];
3381
- if (value === void 0) continue;
3382
- if (value === null) {
3383
- if (!allowClear) return bad(key);
3384
- out[key] = null;
3385
- continue;
3386
- }
3387
- if (typeof value !== "string") return bad(key);
3388
- out[key] = value;
3389
- }
3390
- for (const key of ["maxTurns", "maxBudgetUsd", "timeoutMs"]) {
3391
- const value = raw[key];
3392
- if (value === void 0) continue;
3393
- const num = finite(value);
3394
- if (num === null)
3395
- return { ok: false, message: t("web.schedule_bad_field", { field: key }, lang) };
3396
- out[key] = num;
3397
- }
3398
- for (const key of ["allowTools", "allowRules"]) {
3399
- const value = raw[key];
3400
- if (value === void 0) continue;
3401
- const list = stringList3(value);
3402
- if (list === null)
3403
- return { ok: false, message: t("web.schedule_bad_field", { field: key }, lang) };
3404
- out[key] = list;
3405
- }
3406
- if (raw["allowOperations"] !== void 0) {
3407
- const ops = operationList(raw["allowOperations"]);
3408
- if (ops === null) {
3409
- return {
3410
- ok: false,
3411
- message: t("web.schedule_bad_operation", { types: OPERATION_TYPES.join("/") }, lang)
3412
- };
3413
- }
3414
- out.allowOperations = ops;
3415
- }
3416
- for (const key of ["enabled", "showInSessionList"]) {
3417
- const value = raw[key];
3418
- if (value === void 0) continue;
3419
- if (typeof value !== "boolean") {
3420
- return { ok: false, message: t("web.schedule_bad_field", { field: key }, lang) };
3421
- }
3422
- out[key] = value;
3423
- }
3424
- if (raw["permission"] !== void 0) {
3425
- const level = raw["permission"];
3426
- if (typeof level !== "string" || !isPermissionLevel(level)) {
3427
- return { ok: false, message: t("web.schedule_bad_permission", void 0, lang) };
3428
- }
3429
- out.permission = level;
3430
- }
3431
- if (raw["trigger"] !== void 0) {
3432
- const trigger = parseTrigger(raw["trigger"]);
3433
- if (trigger === null)
3434
- return { ok: false, message: t("web.schedule_bad_trigger", void 0, lang) };
3435
- out.trigger = trigger;
3436
- }
3437
- return { ok: true, value: out };
3438
- }
3439
- function bypassAcknowledged(body) {
3440
- return asRecord(body)["bypassAcknowledged"] === true;
3441
- }
3442
- function parseCreate2(body, lang) {
3443
- const raw = asRecord(body);
3444
- const name = raw["name"];
3445
- const prompt = raw["prompt"];
3446
- const permission = raw["permission"];
3447
- const budget = finite(raw["maxBudgetUsd"]);
3448
- if (typeof name !== "string" || typeof prompt !== "string") {
3449
- return { ok: false, message: t("web.schedule_create_incomplete", void 0, lang) };
3450
- }
3451
- if (typeof permission !== "string" || !isPermissionLevel(permission)) {
3452
- return { ok: false, message: t("web.schedule_bad_permission", void 0, lang) };
3453
- }
3454
- const trigger = parseTrigger(raw["trigger"]);
3455
- if (trigger === null)
3456
- return { ok: false, message: t("web.schedule_bad_trigger", void 0, lang) };
3457
- if (budget === null)
3458
- return { ok: false, message: t("web.schedule_budget_required", void 0, lang) };
3459
- const rest = optionals(raw, false, lang);
3460
- if (!rest.ok) return rest;
3328
+ function toWirePreview2(preview) {
3329
+ const { manifest, source, inventory } = preview;
3461
3330
  return {
3462
- ok: true,
3463
- value: { ...rest.value, name, prompt, permission, trigger, maxBudgetUsd: budget }
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
3464
3348
  };
3465
3349
  }
3466
- function parseUpdate2(body, lang) {
3467
- return optionals(asRecord(body), true, lang);
3468
- }
3469
- function parseFix(body, lang) {
3470
- const runId = asRecord(body)["runId"];
3471
- if (typeof runId !== "string" || runId.trim().length === 0) {
3472
- return { ok: false, message: t("web.schedule_fix_needs_run", void 0, lang) };
3473
- }
3474
- return { ok: true, value: runId };
3475
- }
3476
- // src/schedule/handlers.ts
3477
- var RECENT_RUNS_LIMIT = 100;
3478
- var RUNS_LIMIT = 20;
3479
- var MAX_RECORDING_FRAMES = 4e3;
3480
- function schedulesOf(ctx, res, lang) {
3481
- const control = ctx.runtime.schedules;
3482
- if (!control.storeAvailable) {
3483
- sendError(res, 503, "no-schedule-store", t("web.schedule_store_unavailable", void 0, lang));
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));
3484
3354
  return null;
3485
3355
  }
3486
3356
  return control;
3487
3357
  }
3488
- function toWireRow(control, def) {
3489
- return {
3490
- schedule: def,
3491
- nextRunAt: def.enabled ? nextRunAt(def.trigger, Date.now(), def.startDate, def.endDate) : null,
3492
- allowlistApplies: control.needsAllowlist(def.permission)
3493
- };
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;
3494
3367
  }
3495
- function defaultsOf(ctx) {
3496
- return {
3497
- maxTurns: ctx.runtime.config.maxTurns,
3498
- timeoutMs: SCHEDULE_DEFAULTS.timeoutMs,
3499
- maxBudgetUsd: SCHEDULE_FORM_BUDGET_USD,
3500
- allowTools: SCHEDULE_DEFAULT_ALLOWLIST.allowTools,
3501
- allowOperations: SCHEDULE_DEFAULT_ALLOWLIST.allowOperations,
3502
- allowRules: SCHEDULE_DEFAULT_ALLOWLIST.allowRules
3503
- };
3368
+ function fieldOf(payload, key) {
3369
+ const value = payload[key];
3370
+ return typeof value === "string" ? value.trim() : "";
3504
3371
  }
3505
- async function listSchedules(ctx, res, lang) {
3506
- const control = schedulesOf(ctx, res, lang);
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);
3507
3387
  if (!control) return;
3508
- const capability = await control.capability();
3509
- const payload = {
3510
- schedules: control.list().map((def) => toWireRow(control, def)),
3511
- capability: { backend: capability.backend, canRegister: capability.canRegister },
3512
- defaults: defaultsOf(ctx),
3513
- recentRuns: control.recentRuns(RECENT_RUNS_LIMIT)
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
3514
3408
  };
3515
- sendJson(res, 200, payload);
3516
3409
  }
3517
- function listPending(ctx, res, lang) {
3518
- const control = schedulesOf(ctx, res, lang);
3519
- if (!control) return;
3520
- const items = collectPendingApprovals(control.list(), control.recentRuns(RECENT_RUNS_LIMIT));
3521
- sendJson(res, 200, { items });
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
+ }
3420
+ sendJson(res, 200, {
3421
+ ok: true,
3422
+ preview: toWirePreview2(outcome.preview),
3423
+ token: outcome.token,
3424
+ reason: null,
3425
+ detail: null
3426
+ });
3522
3427
  }
3523
- function listRunning(ctx, res, lang) {
3524
- const control = schedulesOf(ctx, res, lang);
3428
+ async function previewPluginInstall(ctx, req, res, lang) {
3429
+ if (refusedByLan(ctx, res, lang)) return;
3430
+ const control = controlOf(ctx, res, lang);
3525
3431
  if (!control) return;
3526
- const items = control.running().map(({ scheduleId, startedAt }) => ({ scheduleId, startedAt }));
3527
- sendJson(res, 200, { items });
3432
+ const body = await readJsonBody(req);
3433
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3434
+ sendPreview(res, await control.preview(fieldOf(asRecord(body.value), "ref"), lang));
3528
3435
  }
3529
- function getSchedule(ctx, res, id, lang) {
3530
- const control = schedulesOf(ctx, res, lang);
3436
+ async function previewLocalPluginInstall(ctx, req, res, lang) {
3437
+ if (refusedByLan(ctx, res, lang)) return;
3438
+ const control = controlOf(ctx, res, lang);
3531
3439
  if (!control) return;
3532
- const def = control.get(id);
3533
- if (!def) return sendNotFound(res, id, lang);
3534
- sendJson(res, 200, { row: toWireRow(control, def) });
3440
+ const body = await readJsonBody(req);
3441
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3442
+ sendPreview(res, await control.previewLocal(fieldOf(asRecord(body.value), "path"), lang));
3535
3443
  }
3536
- function listRuns(ctx, res, id, lang) {
3537
- const control = schedulesOf(ctx, res, lang);
3444
+ async function previewPluginUpdate(ctx, req, res, lang) {
3445
+ if (refusedByLan(ctx, res, lang)) return;
3446
+ const control = controlOf(ctx, res, lang);
3538
3447
  if (!control) return;
3539
- if (!control.get(id)) return sendNotFound(res, id, lang);
3540
- sendJson(res, 200, { runs: control.runs(id, RUNS_LIMIT) });
3541
- }
3542
- async function createSchedule(ctx, req, res, lang) {
3543
3448
  const body = await readJsonBody(req);
3544
3449
  if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3545
- const parsed = parseCreate2(body.value, lang);
3546
- if (!parsed.ok) return sendError(res, 400, "bad-schedule", parsed.message);
3547
- const control = schedulesOf(ctx, res, lang);
3548
- if (!control) return;
3549
- const saved = await control.create(parsed.value, {
3550
- bypassAcknowledged: bypassAcknowledged(body.value)
3551
- });
3552
- sendJson(res, 200, toSaveResponse(control, saved));
3553
- }
3554
- async function patchSchedule(ctx, req, res, id, lang) {
3555
- const control = schedulesOf(ctx, res, lang);
3556
- if (!control) return;
3557
- if (!control.get(id)) return sendNotFound(res, id, lang);
3558
- const body = await readJsonBody(req);
3559
- if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3560
- const parsed = parseUpdate2(body.value, lang);
3561
- if (!parsed.ok) return sendError(res, 400, "bad-schedule", parsed.message);
3562
- const { enabled, ...rest } = parsed.value;
3563
- const saved = Object.keys(rest).length > 0 ? await control.update(id, rest, { bypassAcknowledged: bypassAcknowledged(body.value) }) : { issues: [] };
3564
- if (saved.issues.length > 0) return sendJson(res, 200, toSaveResponse(control, saved));
3565
- const registration = enabled === void 0 ? saved.registration : await control.setEnabled(id, enabled);
3566
- const next = control.get(id) ?? saved.schedule;
3567
- sendJson(
3568
- res,
3569
- 200,
3570
- toSaveResponse(control, {
3571
- ...next ? { schedule: next } : {},
3572
- issues: [],
3573
- ...registration ? { registration } : {}
3574
- })
3575
- );
3576
- }
3577
- async function deleteSchedule(ctx, res, id, lang) {
3578
- const control = schedulesOf(ctx, res, lang);
3579
- if (!control) return;
3580
- const removed = await control.remove(id);
3581
- sendJson(res, 200, { removed });
3582
- }
3583
- async function runSchedule(ctx, res, id, lang) {
3584
- const control = schedulesOf(ctx, res, lang);
3585
- if (!control) return;
3586
- if (!control.get(id)) return sendNotFound(res, id, lang);
3587
- const result = await control.fire(id, { manual: true });
3588
- const payload = {
3589
- status: result.status,
3590
- exitCode: result.exitCode,
3591
- ...result.run ? { run: result.run } : {},
3592
- ...result.detail === void 0 ? {} : { detail: result.detail }
3593
- };
3594
- sendJson(res, 200, payload);
3595
- }
3596
- async function fixSchedule(ctx, req, res, id, lang) {
3597
- const control = schedulesOf(ctx, res, lang);
3598
- if (!control) return;
3599
- const def = control.get(id);
3600
- if (!def) return sendNotFound(res, id, lang);
3601
- const body = await readJsonBody(req);
3602
- if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3603
- const parsed = parseFix(body.value, lang);
3604
- if (!parsed.ok) return sendError(res, 400, "bad-schedule", parsed.message);
3605
- const run = control.runs(id, RUNS_LIMIT).find((one) => one.id === parsed.value);
3606
- if (!run) {
3607
- return sendError(
3608
- res,
3609
- 404,
3610
- "not-found",
3611
- t("web.schedule_run_unknown", { runId: parsed.value }, lang)
3612
- );
3613
- }
3614
- const added = unfixedRules(def.allowRules, run.pendingApprovals);
3615
- if (added.length === 0) {
3616
- return sendJson(res, 200, {
3617
- added: [],
3618
- row: toWireRow(control, def)
3619
- });
3620
- }
3621
- const saved = await control.update(id, { allowRules: [...def.allowRules, ...added] });
3622
- const next = saved.schedule ?? def;
3623
- sendJson(res, 200, {
3624
- added,
3625
- row: toWireRow(control, next)
3626
- });
3627
- }
3628
- function getRecording(ctx, res, id, runId, lang) {
3629
- const control = schedulesOf(ctx, res, lang);
3630
- if (!control) return;
3631
- if (!control.get(id)) return sendNotFound(res, id, lang);
3632
- const run = control.runs(id, RUNS_LIMIT).find((one) => one.id === runId);
3633
- if (!run) {
3634
- return sendError(res, 404, "not-found", t("web.schedule_run_unknown", { runId }, lang));
3635
- }
3636
- sendJson(res, 200, toRecording(run));
3637
- }
3638
- function toRecording(run) {
3639
- if (run.logPath === void 0) {
3640
- return { runId: run.id, frames: [], missing: true, truncated: false };
3641
- }
3642
- const frames = coalesceRecording(readRecording(run.logPath));
3643
- if (frames.length === 0) {
3644
- return { runId: run.id, frames: [], missing: true, truncated: false };
3645
- }
3646
- const truncated = frames.length > MAX_RECORDING_FRAMES;
3647
- return {
3648
- runId: run.id,
3649
- frames: truncated ? frames.slice(-MAX_RECORDING_FRAMES) : frames,
3650
- missing: false,
3651
- truncated
3652
- };
3653
- }
3654
- function toSaveResponse(control, saved) {
3655
- return {
3656
- ok: saved.issues.length === 0,
3657
- issues: saved.issues,
3658
- ...saved.schedule ? { row: toWireRow(control, saved.schedule) } : {},
3659
- ...saved.registration ? { registration: saved.registration } : {}
3660
- };
3661
- }
3662
- function sendNotFound(res, id, lang) {
3663
- sendError(res, 404, "unknown-schedule", t("web.schedule_unknown", { id }, lang));
3664
- }
3665
- // src/skill-import.ts
3666
- function pathOf(payload) {
3667
- return typeof payload["path"] === "string" ? payload["path"].trim() : "";
3668
- }
3669
- function toWireIssues2(issues) {
3670
- return (issues ?? []).map((i) => ({ path: i.path, message: i.message }));
3671
- }
3672
- function toWireEntry(entry) {
3673
- return {
3674
- name: entry.name,
3675
- category: entry.category,
3676
- description: entry.description,
3677
- files: entry.files,
3678
- bytes: entry.bytes,
3679
- conflict: entry.conflict ?? null
3680
- };
3450
+ sendPreview(res, await control.previewUpdate(fieldOf(asRecord(body.value), "name"), lang));
3681
3451
  }
3682
- async function previewSkillImport(ctx, req, res, lang) {
3683
- const body = await readJsonBody(req);
3684
- if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3685
- const source = pathOf(asRecord(body.value));
3686
- const outcome = await ctx.runtime.skillWrite.preview(source, lang);
3452
+ function sendAction(res, control, outcome) {
3687
3453
  if (!outcome.ok) {
3688
3454
  return sendJson(res, 200, {
3689
3455
  ok: false,
3690
- source,
3691
- form: null,
3692
- skills: [],
3693
- issues: toWireIssues2(outcome.issues),
3694
- token: "",
3456
+ entry: null,
3457
+ pendingRestart: control.pendingRestart,
3695
3458
  detail: outcome.detail,
3696
3459
  reason: outcome.reason
3697
3460
  });
3698
3461
  }
3699
- const preview = outcome.preview;
3462
+ const fresh = "record" in outcome ? control.list().find((p) => p.name === outcome.record.name) : void 0;
3700
3463
  sendJson(res, 200, {
3701
3464
  ok: true,
3702
- source: preview.source,
3703
- form: preview.form,
3704
- skills: preview.skills.map(toWireEntry),
3705
- issues: toWireIssues2(preview.issues),
3706
- token: preview.token,
3707
- detail: null,
3465
+ entry: fresh ? toWirePluginEntry(fresh, displayNamesOf(control)) : null,
3466
+ pendingRestart: control.pendingRestart,
3467
+ detail: "detail" in outcome ? outcome.detail : null,
3708
3468
  reason: null
3709
3469
  });
3710
3470
  }
3711
- async function importSkills(ctx, req, res, 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;
3712
3475
  const body = await readJsonBody(req);
3713
3476
  if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3714
3477
  const payload = asRecord(body.value);
3715
- const source = pathOf(payload);
3716
- const token = typeof payload["token"] === "string" ? payload["token"] : "";
3717
- const outcome = await ctx.runtime.skillWrite.import(source, token, lang);
3718
- if (!outcome.ok) {
3719
- return sendJson(res, 200, {
3720
- ok: false,
3721
- imported: [],
3722
- skipped: toWireIssues2(outcome.issues),
3723
- detail: outcome.detail,
3724
- reason: outcome.reason
3725
- });
3726
- }
3727
- sendJson(res, 200, {
3728
- ok: outcome.imported.length > 0,
3729
- imported: [...outcome.imported],
3730
- skipped: toWireIssues2(outcome.skipped),
3731
- detail: null,
3732
- reason: null
3733
- });
3478
+ sendAction(
3479
+ res,
3480
+ control,
3481
+ await control.install(fieldOf(payload, "ref"), fieldOf(payload, "token"), lang)
3482
+ );
3734
3483
  }
3735
- // src/skill-remove.ts
3736
- async function removeSkill(ctx, req, res, lang) {
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;
3737
3488
  const body = await readJsonBody(req);
3738
3489
  if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3739
3490
  const payload = asRecord(body.value);
3740
- const name = typeof payload["name"] === "string" ? payload["name"].trim() : "";
3741
- const outcome = await ctx.runtime.skillWrite.remove(name, lang);
3742
- if (!outcome.ok) {
3743
- return sendJson(res, 200, {
3744
- ok: false,
3745
- name: "",
3746
- path: null,
3747
- detail: outcome.detail,
3748
- reason: outcome.reason
3749
- });
3750
- }
3751
- sendJson(res, 200, {
3752
- ok: true,
3753
- name: outcome.name,
3754
- path: outcome.path,
3755
- detail: null,
3756
- reason: null
3757
- });
3758
- }
3759
- function refusedByLan2(ctx, res, lang) {
3760
- if (!ctx.lanExposed) return false;
3761
- sendError(res, 403, "skill-stage-lan-exposed", t("web.skill_stage_lan_exposed", void 0, lang));
3762
- return true;
3491
+ sendAction(
3492
+ res,
3493
+ control,
3494
+ await control.installLocal(fieldOf(payload, "path"), fieldOf(payload, "token"), lang)
3495
+ );
3763
3496
  }
3764
- async function stageSkillDrop(ctx, req, res, lang) {
3765
- if (refusedByLan2(ctx, res, lang)) return;
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(
3506
+ res,
3507
+ await control.stage({ kind: "path", path: fieldOf(asRecord(body.value), "path") }, lang)
3508
+ );
3509
+ }
3766
3510
  const raw = await readRawBody(req, PLUGIN_UPLOAD_BODY_LIMIT, lang);
3767
3511
  if (!raw.ok) return sendError(res, raw.status, "bad-body", raw.message);
3768
- const outcome = await ctx.runtime.skillStage.stage(
3769
- { name: nameParam3(req), data: raw.value },
3770
- lang
3512
+ return sendStage(
3513
+ res,
3514
+ await control.stage({ kind: "bytes", name: nameParam2(req), data: raw.value }, lang)
3771
3515
  );
3772
- sendJson(res, 200, {
3773
- ok: outcome.ok,
3774
- path: outcome.ok ? outcome.path : null,
3775
- detail: outcome.ok ? null : outcome.detail,
3776
- reason: outcome.ok ? null : outcome.reason
3777
- });
3778
3516
  }
3779
- function nameParam3(req) {
3517
+ function nameParam2(req) {
3780
3518
  try {
3781
3519
  return new URL(req.url ?? "/", "http://127.0.0.1").searchParams.get("name") ?? "";
3782
3520
  } catch {
3783
3521
  return "";
3784
3522
  }
3785
3523
  }
3786
- // src/sse.ts
3787
- var DEFAULT_HEARTBEAT_MS = 15e3;
3788
- function parseLastEventId(req) {
3789
- const raw = req.headers["last-event-id"];
3790
- const value = Array.isArray(raw) ? raw[0] : raw;
3791
- if (typeof value !== "string" || value.trim().length === 0) return void 0;
3792
- const parsed = Number(value.trim());
3793
- return Number.isInteger(parsed) && parsed >= 0 ? parsed : void 0;
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);
3794
3541
  }
3795
- var SseConnection = class {
3796
- constructor(res, heartbeatMs = DEFAULT_HEARTBEAT_MS) {
3797
- this.res = res;
3798
- res.writeHead(200, {
3799
- "Content-Type": "text/event-stream; charset=utf-8",
3800
- "Cache-Control": "no-cache, no-transform",
3801
- Connection: "keep-alive",
3802
- "X-Accel-Buffering": "no",
3803
- "Referrer-Policy": "no-referrer",
3804
- "X-Content-Type-Options": "nosniff"
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;
3546
+ const body = await readJsonBody(req);
3547
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3548
+ const payload = asRecord(body.value);
3549
+ sendAction(
3550
+ res,
3551
+ control,
3552
+ await control.update(fieldOf(payload, "name"), fieldOf(payload, "token"), lang)
3553
+ );
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) {
3564
+ sendJson(res, 200, {
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
3569
+ });
3570
+ }
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));
3578
+ }
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));
3586
+ }
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(
3595
+ res,
3596
+ await control.upload(
3597
+ fieldOf(payload, "path"),
3598
+ fieldOf(payload, "token"),
3599
+ uploadFormOf(payload),
3600
+ lang
3601
+ )
3602
+ );
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;
3609
+ const body = await readJsonBody(req);
3610
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3611
+ await run(control, asRecord(body.value));
3612
+ }
3613
+ function sendUploadPreview(res, outcome) {
3614
+ if (!outcome.ok) {
3615
+ return sendJson(res, 200, {
3616
+ ok: false,
3617
+ plan: null,
3618
+ token: null,
3619
+ prefill: null,
3620
+ fromManifest: null,
3621
+ reason: outcome.reason,
3622
+ detail: outcome.detail
3805
3623
  });
3806
- res.flushHeaders();
3807
- this.heartbeat = setInterval(() => this.ping(), heartbeatMs);
3808
- this.heartbeat.unref();
3809
3624
  }
3810
- res;
3811
- heartbeat;
3812
- closed = false;
3813
- send(frame) {
3814
- if (this.closed) return;
3815
- const body = frame.seq > 0 ? `id: ${frame.seq}
3816
- ` : "";
3817
- this.res.write(`${body}data: ${JSON.stringify(frame)}
3818
-
3819
- `);
3625
+ const { manifest, files, totalBytes, suspicious, skippedLinks } = outcome.plan;
3626
+ sendJson(res, 200, {
3627
+ ok: true,
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
3643
+ });
3644
+ }
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
+ });
3652
+ }
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
+ });
3820
3665
  }
3821
- ping() {
3822
- if (this.closed) return;
3823
- this.res.write(": ping\n\n");
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) {
3861
+ const raw = asRecord(value);
3862
+ const kind = raw["kind"];
3863
+ const at = raw["at"];
3864
+ if (kind === "interval") {
3865
+ const every = finite(raw["everyMinutes"]);
3866
+ return every === null ? null : { kind: "interval", everyMinutes: every };
3867
+ }
3868
+ if (typeof at !== "string") return null;
3869
+ if (kind === "once") {
3870
+ const date = raw["date"];
3871
+ return typeof date === "string" ? { kind: "once", date, at } : null;
3872
+ }
3873
+ if (kind !== "cron") return null;
3874
+ const cycle = raw["cycle"];
3875
+ if (cycle === "daily") return { kind: "cron", cycle: "daily", at };
3876
+ if (cycle === "weekly") {
3877
+ const days = weekdayList(raw["weekdays"]);
3878
+ return days === null ? null : { kind: "cron", cycle: "weekly", at, weekdays: days };
3879
+ }
3880
+ if (cycle === "monthly") {
3881
+ const days = numberList(raw["days"]);
3882
+ return days === null ? null : { kind: "cron", cycle: "monthly", at, days };
3883
+ }
3884
+ return null;
3885
+ }
3886
+ function weekdayList(value) {
3887
+ if (!Array.isArray(value)) return null;
3888
+ const out = [];
3889
+ for (const item of value) {
3890
+ if (typeof item !== "number" || !Number.isInteger(item) || item < 0 || item > 6) return null;
3891
+ out.push(item);
3892
+ }
3893
+ return out;
3894
+ }
3895
+ function numberList(value) {
3896
+ if (!Array.isArray(value)) return null;
3897
+ const out = [];
3898
+ for (const item of value) {
3899
+ if (typeof item !== "number" || !Number.isInteger(item)) return null;
3900
+ out.push(item);
3901
+ }
3902
+ return out;
3903
+ }
3904
+ var CLEARABLE = ["model", "role", "workDir", "startDate", "endDate"];
3905
+ function optionals(raw, allowClear, lang) {
3906
+ const out = {};
3907
+ const bad = (field) => ({
3908
+ ok: false,
3909
+ message: t("web.schedule_bad_field", { field }, lang)
3910
+ });
3911
+ for (const key of ["name", "prompt"]) {
3912
+ const value = raw[key];
3913
+ if (value === void 0) continue;
3914
+ if (typeof value !== "string") return bad(key);
3915
+ out[key] = value;
3916
+ }
3917
+ for (const key of CLEARABLE) {
3918
+ const value = raw[key];
3919
+ if (value === void 0) continue;
3920
+ if (value === null) {
3921
+ if (!allowClear) return bad(key);
3922
+ out[key] = null;
3923
+ continue;
3924
+ }
3925
+ if (typeof value !== "string") return bad(key);
3926
+ out[key] = value;
3927
+ }
3928
+ for (const key of ["maxTurns", "maxBudgetUsd", "timeoutMs"]) {
3929
+ const value = raw[key];
3930
+ if (value === void 0) continue;
3931
+ const num = finite(value);
3932
+ if (num === null)
3933
+ return { ok: false, message: t("web.schedule_bad_field", { field: key }, lang) };
3934
+ out[key] = num;
3935
+ }
3936
+ for (const key of ["allowTools", "allowRules"]) {
3937
+ const value = raw[key];
3938
+ if (value === void 0) continue;
3939
+ const list = stringList3(value);
3940
+ if (list === null)
3941
+ return { ok: false, message: t("web.schedule_bad_field", { field: key }, lang) };
3942
+ out[key] = list;
3943
+ }
3944
+ if (raw["allowOperations"] !== void 0) {
3945
+ const ops = operationList(raw["allowOperations"]);
3946
+ if (ops === null) {
3947
+ return {
3948
+ ok: false,
3949
+ message: t("web.schedule_bad_operation", { types: OPERATION_TYPES.join("/") }, lang)
3950
+ };
3951
+ }
3952
+ out.allowOperations = ops;
3953
+ }
3954
+ for (const key of ["enabled", "showInSessionList"]) {
3955
+ const value = raw[key];
3956
+ if (value === void 0) continue;
3957
+ if (typeof value !== "boolean") {
3958
+ return { ok: false, message: t("web.schedule_bad_field", { field: key }, lang) };
3959
+ }
3960
+ out[key] = value;
3961
+ }
3962
+ if (raw["permission"] !== void 0) {
3963
+ const level = raw["permission"];
3964
+ if (typeof level !== "string" || !isPermissionLevel(level)) {
3965
+ return { ok: false, message: t("web.schedule_bad_permission", void 0, lang) };
3966
+ }
3967
+ out.permission = level;
3968
+ }
3969
+ if (raw["trigger"] !== void 0) {
3970
+ const trigger = parseTrigger(raw["trigger"]);
3971
+ if (trigger === null)
3972
+ return { ok: false, message: t("web.schedule_bad_trigger", void 0, lang) };
3973
+ out.trigger = trigger;
3974
+ }
3975
+ return { ok: true, value: out };
3976
+ }
3977
+ function bypassAcknowledged(body) {
3978
+ return asRecord(body)["bypassAcknowledged"] === true;
3979
+ }
3980
+ function parseCreate2(body, lang) {
3981
+ const raw = asRecord(body);
3982
+ const name = raw["name"];
3983
+ const prompt = raw["prompt"];
3984
+ const permission = raw["permission"];
3985
+ const budget = finite(raw["maxBudgetUsd"]);
3986
+ if (typeof name !== "string" || typeof prompt !== "string") {
3987
+ return { ok: false, message: t("web.schedule_create_incomplete", void 0, lang) };
3824
3988
  }
3825
- close() {
3826
- if (this.closed) return;
3827
- this.closed = true;
3828
- clearInterval(this.heartbeat);
3829
- this.res.end();
3989
+ if (typeof permission !== "string" || !isPermissionLevel(permission)) {
3990
+ return { ok: false, message: t("web.schedule_bad_permission", void 0, lang) };
3830
3991
  }
3831
- };
3832
- var TASK_TAIL_BYTES = 8 * 1024;
3833
- var LIVE_TASKS = {
3834
- list: (sessionId) => listBackgroundTasks(sessionId),
3835
- output: (sessionId, id, since) => backgroundTaskOutput(sessionId, id, since)
3836
- };
3837
- function toWireTask(sessionId, info, registry) {
3838
- const since = Math.max(0, info.outputBytes - TASK_TAIL_BYTES);
3839
- const chunk = registry.output(sessionId, info.id, since);
3840
- if (!chunk) return { info, output: "", omittedBytes: info.outputBytes };
3992
+ const trigger = parseTrigger(raw["trigger"]);
3993
+ if (trigger === null)
3994
+ return { ok: false, message: t("web.schedule_bad_trigger", void 0, lang) };
3995
+ if (budget === null)
3996
+ return { ok: false, message: t("web.schedule_budget_required", void 0, lang) };
3997
+ const rest = optionals(raw, false, lang);
3998
+ if (!rest.ok) return rest;
3841
3999
  return {
3842
- info,
3843
- output: chunk.output,
3844
- omittedBytes: Math.max(0, chunk.nextCursor - chunk.output.length)
4000
+ ok: true,
4001
+ value: { ...rest.value, name, prompt, permission, trigger, maxBudgetUsd: budget }
3845
4002
  };
3846
4003
  }
3847
- function collectTasks(sessionId, registry) {
3848
- return { tasks: registry.list(sessionId).map((info) => toWireTask(sessionId, info, registry)) };
4004
+ function parseUpdate2(body, lang) {
4005
+ return optionals(asRecord(body), true, lang);
3849
4006
  }
3850
- function getTasks(ctx, res, sessionId, _lang, registry = LIVE_TASKS) {
3851
- if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
3852
- return sendUnknownSession(res, sessionId);
4007
+ function parseFix(body, lang) {
4008
+ const runId = asRecord(body)["runId"];
4009
+ if (typeof runId !== "string" || runId.trim().length === 0) {
4010
+ return { ok: false, message: t("web.schedule_fix_needs_run", void 0, lang) };
3853
4011
  }
3854
- sendJson(res, 200, collectTasks(sessionId, registry));
4012
+ return { ok: true, value: runId };
3855
4013
  }
3856
- var DIR_PAGE_LIMIT = 1e3;
3857
- function workspaceHome() {
3858
- return homedir();
4014
+ // src/schedule/handlers.ts
4015
+ var RECENT_RUNS_LIMIT = 100;
4016
+ var RUNS_LIMIT = 20;
4017
+ var MAX_RECORDING_FRAMES = 4e3;
4018
+ function schedulesOf(ctx, res, lang) {
4019
+ const control = ctx.runtime.schedules;
4020
+ if (!control.storeAvailable) {
4021
+ sendError(res, 503, "no-schedule-store", t("web.schedule_store_unavailable", void 0, lang));
4022
+ return null;
4023
+ }
4024
+ return control;
3859
4025
  }
3860
- function workspaceAnchors(workspaces) {
3861
- const seen = /* @__PURE__ */ new Set();
3862
- const entries = [];
3863
- const known = new Set(workspaces.known().map((one) => resolve(one.root)));
3864
- const push = (raw, group) => {
3865
- const path = resolve(raw);
3866
- if (seen.has(path)) return;
3867
- seen.add(path);
3868
- entries.push({
3869
- name: displayName2(path),
3870
- path,
3871
- known: known.has(path),
3872
- readable: canList(path),
3873
- group
3874
- });
4026
+ function toWireRow(control, def) {
4027
+ return {
4028
+ schedule: def,
4029
+ nextRunAt: def.enabled ? nextRunAt(def.trigger, Date.now(), def.startDate, def.endDate) : null,
4030
+ allowlistApplies: control.needsAllowlist(def.permission)
3875
4031
  };
3876
- push(homedir(), "home");
3877
- push(process.cwd(), "cwd");
3878
- for (const one of workspaces.known()) push(one.root, "known");
4032
+ }
4033
+ function defaultsOf(ctx) {
3879
4034
  return {
3880
- path: null,
3881
- parent: null,
3882
- entries,
3883
- omitted: 0,
3884
- hidden: 0
4035
+ maxTurns: ctx.runtime.config.maxTurns,
4036
+ timeoutMs: SCHEDULE_DEFAULTS.timeoutMs,
4037
+ maxBudgetUsd: SCHEDULE_FORM_BUDGET_USD,
4038
+ allowTools: SCHEDULE_DEFAULT_ALLOWLIST.allowTools,
4039
+ allowOperations: SCHEDULE_DEFAULT_ALLOWLIST.allowOperations,
4040
+ allowRules: SCHEDULE_DEFAULT_ALLOWLIST.allowRules
3885
4041
  };
3886
4042
  }
3887
- function listWorkspaceDirs(workspaces, raw, opts = {}) {
3888
- if (raw.length === 0 || !isAbsolute(raw)) {
3889
- return { ok: false, reason: "not-absolute", path: raw };
3890
- }
3891
- const path = resolve(raw);
3892
- let dirents;
3893
- try {
3894
- dirents = readdirSync(path, { withFileTypes: true });
3895
- } catch (cause) {
3896
- return { ok: false, reason: readdirFailure(cause), path };
4043
+ async function listSchedules(ctx, res, lang) {
4044
+ const control = schedulesOf(ctx, res, lang);
4045
+ if (!control) return;
4046
+ const capability = await control.capability();
4047
+ const payload = {
4048
+ schedules: control.list().map((def) => toWireRow(control, def)),
4049
+ capability: { backend: capability.backend, canRegister: capability.canRegister },
4050
+ defaults: defaultsOf(ctx),
4051
+ recentRuns: control.recentRuns(RECENT_RUNS_LIMIT)
4052
+ };
4053
+ sendJson(res, 200, payload);
4054
+ }
4055
+ function listPending(ctx, res, lang) {
4056
+ const control = schedulesOf(ctx, res, lang);
4057
+ if (!control) return;
4058
+ const items = collectPendingApprovals(control.list(), control.recentRuns(RECENT_RUNS_LIMIT));
4059
+ sendJson(res, 200, { items });
4060
+ }
4061
+ function listRunning(ctx, res, lang) {
4062
+ const control = schedulesOf(ctx, res, lang);
4063
+ if (!control) return;
4064
+ const items = control.running().map(({ scheduleId, startedAt }) => ({ scheduleId, startedAt }));
4065
+ sendJson(res, 200, { items });
4066
+ }
4067
+ function getSchedule(ctx, res, id, lang) {
4068
+ const control = schedulesOf(ctx, res, lang);
4069
+ if (!control) return;
4070
+ const def = control.get(id);
4071
+ if (!def) return sendNotFound(res, id, lang);
4072
+ sendJson(res, 200, { row: toWireRow(control, def) });
4073
+ }
4074
+ function listRuns(ctx, res, id, lang) {
4075
+ const control = schedulesOf(ctx, res, lang);
4076
+ if (!control) return;
4077
+ if (!control.get(id)) return sendNotFound(res, id, lang);
4078
+ sendJson(res, 200, { runs: control.runs(id, RUNS_LIMIT) });
4079
+ }
4080
+ async function createSchedule(ctx, req, res, lang) {
4081
+ const body = await readJsonBody(req);
4082
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
4083
+ const parsed = parseCreate2(body.value, lang);
4084
+ if (!parsed.ok) return sendError(res, 400, "bad-schedule", parsed.message);
4085
+ const control = schedulesOf(ctx, res, lang);
4086
+ if (!control) return;
4087
+ const saved = await control.create(parsed.value, {
4088
+ bypassAcknowledged: bypassAcknowledged(body.value)
4089
+ });
4090
+ sendJson(res, 200, toSaveResponse(control, saved));
4091
+ }
4092
+ async function patchSchedule(ctx, req, res, id, lang) {
4093
+ const control = schedulesOf(ctx, res, lang);
4094
+ if (!control) return;
4095
+ if (!control.get(id)) return sendNotFound(res, id, lang);
4096
+ const body = await readJsonBody(req);
4097
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
4098
+ const parsed = parseUpdate2(body.value, lang);
4099
+ if (!parsed.ok) return sendError(res, 400, "bad-schedule", parsed.message);
4100
+ const { enabled, ...rest } = parsed.value;
4101
+ const saved = Object.keys(rest).length > 0 ? await control.update(id, rest, { bypassAcknowledged: bypassAcknowledged(body.value) }) : { issues: [] };
4102
+ if (saved.issues.length > 0) return sendJson(res, 200, toSaveResponse(control, saved));
4103
+ const registration = enabled === void 0 ? saved.registration : await control.setEnabled(id, enabled);
4104
+ const next = control.get(id) ?? saved.schedule;
4105
+ sendJson(
4106
+ res,
4107
+ 200,
4108
+ toSaveResponse(control, {
4109
+ ...next ? { schedule: next } : {},
4110
+ issues: [],
4111
+ ...registration ? { registration } : {}
4112
+ })
4113
+ );
4114
+ }
4115
+ async function deleteSchedule(ctx, res, id, lang) {
4116
+ const control = schedulesOf(ctx, res, lang);
4117
+ if (!control) return;
4118
+ const removed = await control.remove(id);
4119
+ sendJson(res, 200, { removed });
4120
+ }
4121
+ async function runSchedule(ctx, res, id, lang) {
4122
+ const control = schedulesOf(ctx, res, lang);
4123
+ if (!control) return;
4124
+ if (!control.get(id)) return sendNotFound(res, id, lang);
4125
+ const result = await control.fire(id, { manual: true });
4126
+ const payload = {
4127
+ status: result.status,
4128
+ exitCode: result.exitCode,
4129
+ ...result.run ? { run: result.run } : {},
4130
+ ...result.detail === void 0 ? {} : { detail: result.detail }
4131
+ };
4132
+ sendJson(res, 200, payload);
4133
+ }
4134
+ async function fixSchedule(ctx, req, res, id, lang) {
4135
+ const control = schedulesOf(ctx, res, lang);
4136
+ if (!control) return;
4137
+ const def = control.get(id);
4138
+ if (!def) return sendNotFound(res, id, lang);
4139
+ const body = await readJsonBody(req);
4140
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
4141
+ const parsed = parseFix(body.value, lang);
4142
+ if (!parsed.ok) return sendError(res, 400, "bad-schedule", parsed.message);
4143
+ const run = control.runs(id, RUNS_LIMIT).find((one) => one.id === parsed.value);
4144
+ if (!run) {
4145
+ return sendError(
4146
+ res,
4147
+ 404,
4148
+ "not-found",
4149
+ t("web.schedule_run_unknown", { runId: parsed.value }, lang)
4150
+ );
3897
4151
  }
3898
- const known = new Set(workspaces.known().map((one) => resolve(one.root)));
3899
- const kept = [];
3900
- let omitted = 0;
3901
- let hidden = 0;
3902
- for (const dirent of dirents) {
3903
- if (!dirent.isDirectory()) continue;
3904
- if (!opts.hidden && dirent.name.startsWith(".")) {
3905
- hidden += 1;
3906
- continue;
3907
- }
3908
- if (kept.length >= DIR_PAGE_LIMIT) {
3909
- omitted += 1;
3910
- continue;
3911
- }
3912
- const child = resolve(path, dirent.name);
3913
- kept.push({
3914
- name: dirent.name,
3915
- path: child,
3916
- known: known.has(child),
3917
- readable: canList(child),
3918
- group: "child"
4152
+ const added = unfixedRules(def.allowRules, run.pendingApprovals);
4153
+ if (added.length === 0) {
4154
+ return sendJson(res, 200, {
4155
+ added: [],
4156
+ row: toWireRow(control, def)
3919
4157
  });
3920
4158
  }
3921
- return {
3922
- ok: true,
3923
- view: {
3924
- path,
3925
- parent: parentOf(path),
3926
- entries: kept,
3927
- omitted,
3928
- hidden
3929
- }
3930
- };
4159
+ const saved = await control.update(id, { allowRules: [...def.allowRules, ...added] });
4160
+ const next = saved.schedule ?? def;
4161
+ sendJson(res, 200, {
4162
+ added,
4163
+ row: toWireRow(control, next)
4164
+ });
3931
4165
  }
3932
- function readdirFailure(cause) {
3933
- const code = cause?.code;
3934
- if (code === "EACCES" || code === "EPERM") return "denied";
3935
- if (code === "ENOTDIR") return "not-a-directory";
3936
- return "missing";
4166
+ function getRecording(ctx, res, id, runId, lang) {
4167
+ const control = schedulesOf(ctx, res, lang);
4168
+ if (!control) return;
4169
+ if (!control.get(id)) return sendNotFound(res, id, lang);
4170
+ const run = control.runs(id, RUNS_LIMIT).find((one) => one.id === runId);
4171
+ if (!run) {
4172
+ return sendError(res, 404, "not-found", t("web.schedule_run_unknown", { runId }, lang));
4173
+ }
4174
+ sendJson(res, 200, toRecording(run));
3937
4175
  }
3938
- function canList(path) {
3939
- try {
3940
- accessSync(path, constants.R_OK | constants.X_OK);
3941
- return true;
3942
- } catch {
3943
- return false;
4176
+ function toRecording(run) {
4177
+ if (run.logPath === void 0) {
4178
+ return { runId: run.id, frames: [], missing: true, truncated: false };
4179
+ }
4180
+ const frames = coalesceRecording(readRecording(run.logPath));
4181
+ if (frames.length === 0) {
4182
+ return { runId: run.id, frames: [], missing: true, truncated: false };
3944
4183
  }
4184
+ const truncated = frames.length > MAX_RECORDING_FRAMES;
4185
+ return {
4186
+ runId: run.id,
4187
+ frames: truncated ? frames.slice(-MAX_RECORDING_FRAMES) : frames,
4188
+ missing: false,
4189
+ truncated
4190
+ };
3945
4191
  }
3946
- function parentOf(path) {
3947
- const up = dirname(path);
3948
- return up === path ? null : up;
4192
+ function toSaveResponse(control, saved) {
4193
+ return {
4194
+ ok: saved.issues.length === 0,
4195
+ issues: saved.issues,
4196
+ ...saved.schedule ? { row: toWireRow(control, saved.schedule) } : {},
4197
+ ...saved.registration ? { registration: saved.registration } : {}
4198
+ };
3949
4199
  }
3950
- function displayName2(path) {
3951
- return basename(path) || path;
4200
+ function sendNotFound(res, id, lang) {
4201
+ sendError(res, 404, "unknown-schedule", t("web.schedule_unknown", { id }, lang));
3952
4202
  }
3953
- function present(value) {
3954
- return value !== void 0 && value !== "";
4203
+ // src/skill-import.ts
4204
+ function pathOf(payload) {
4205
+ return typeof payload["path"] === "string" ? payload["path"].trim() : "";
3955
4206
  }
3956
- function resolveNativeDirPicker(facts) {
3957
- return isSameMachine(facts);
4207
+ function toWireIssues2(issues) {
4208
+ return (issues ?? []).map((i) => ({ path: i.path, message: i.message }));
3958
4209
  }
3959
- function isSameMachine(facts) {
3960
- if (facts.lanExposed) return false;
3961
- if (present(facts.env.SSH_CONNECTION) || present(facts.env.SSH_TTY)) return false;
3962
- return facts.platform === "darwin" || facts.platform === "win32";
4210
+ function toWireEntry(entry) {
4211
+ return {
4212
+ name: entry.name,
4213
+ category: entry.category,
4214
+ description: entry.description,
4215
+ files: entry.files,
4216
+ bytes: entry.bytes,
4217
+ conflict: entry.conflict ?? null
4218
+ };
3963
4219
  }
3964
- function nativePickerCommands(platform, prompt) {
3965
- if (platform === "darwin") {
3966
- return [
3967
- {
3968
- command: "osascript",
3969
- args: [
3970
- "-e",
3971
- `set epochChosenFolder to choose folder with prompt ${asAppleScriptString(prompt)}`,
3972
- "-e",
3973
- "POSIX path of epochChosenFolder"
3974
- ]
3975
- }
3976
- ];
3977
- }
3978
- if (platform === "win32") {
3979
- const script = win32PickScript(prompt);
3980
- return [
3981
- { command: "pwsh", args: ["-NoProfile", "-STA", "-Command", script] },
3982
- { command: "powershell.exe", args: ["-NoProfile", "-STA", "-Command", script] }
3983
- ];
4220
+ async function previewSkillImport(ctx, req, res, lang) {
4221
+ const body = await readJsonBody(req);
4222
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
4223
+ const source = pathOf(asRecord(body.value));
4224
+ const outcome = await ctx.runtime.skillWrite.preview(source, lang);
4225
+ if (!outcome.ok) {
4226
+ return sendJson(res, 200, {
4227
+ ok: false,
4228
+ source,
4229
+ form: null,
4230
+ skills: [],
4231
+ issues: toWireIssues2(outcome.issues),
4232
+ token: "",
4233
+ detail: outcome.detail,
4234
+ reason: outcome.reason
4235
+ });
3984
4236
  }
3985
- return null;
3986
- }
3987
- function win32PickScript(prompt) {
3988
- return [
3989
- "Add-Type -AssemblyName System.Windows.Forms",
3990
- "Add-Type -AssemblyName System.Drawing",
3991
- "$epochOwner = New-Object System.Windows.Forms.Form",
3992
- "$epochOwner.FormBorderStyle = 'None'",
3993
- "$epochOwner.ShowInTaskbar = $false",
3994
- "$epochOwner.TopMost = $true",
3995
- "$epochOwner.StartPosition = 'Manual'",
3996
- "$epochOwner.Size = New-Object System.Drawing.Size 1, 1",
3997
- "$epochOwner.Location = New-Object System.Drawing.Point -32000, -32000",
3998
- "$epochOwner.Show()",
3999
- "$epochOwner.Activate()",
4000
- "try {",
4001
- "Add-Type -Namespace EpochPick -Name Win -MemberDefinition @'",
4002
- '[DllImport("user32.dll")] public static extern IntPtr GetWindow(IntPtr h, uint cmd);',
4003
- '[DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr h, IntPtr after, int x, int y, int cx, int cy, uint flags);',
4004
- '[DllImport("user32.dll")] public static extern void SwitchToThisWindow(IntPtr h, bool useAltTab);',
4005
- '[DllImport("user32.dll")] public static extern int GetWindowLong(IntPtr h, int i);',
4006
- '[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();',
4007
- '[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, IntPtr pid);',
4008
- '[DllImport("user32.dll")] public static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach);',
4009
- '[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);',
4010
- '[DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr h);',
4011
- '[DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();',
4012
- "static bool Raise(IntPtr h) {",
4013
- " IntPtr fg = GetForegroundWindow();",
4014
- " uint fgThread = GetWindowThreadProcessId(fg, IntPtr.Zero);",
4015
- " uint me = GetCurrentThreadId();",
4016
- " bool attached = false;",
4017
- " if (fgThread != 0 && fgThread != me) { attached = AttachThreadInput(me, fgThread, true); }",
4018
- " bool okPos = SetWindowPos(h, new IntPtr(-1), 0, 0, 0, 0, 3);",
4019
- " BringWindowToTop(h);",
4020
- " bool okFg = SetForegroundWindow(h);",
4021
- " SwitchToThisWindow(h, true);",
4022
- " if (attached) { AttachThreadInput(me, fgThread, false); }",
4023
- " return okPos && okFg;",
4024
- "}",
4025
- "public static void RaiseSoon(IntPtr owner) {",
4026
- " System.Threading.Thread t = new System.Threading.Thread(delegate() {",
4027
- ' string note = "dialog window never appeared";',
4028
- " for (int i = 0; i < 400; i++) {",
4029
- " System.Threading.Thread.Sleep(30);",
4030
- " IntPtr dlg = GetWindow(owner, 6);",
4031
- " if (dlg == IntPtr.Zero) { continue; }",
4032
- " bool ok = false;",
4033
- " for (int k = 0; k < 4; k++) {",
4034
- " Raise(owner);",
4035
- " ok = Raise(dlg) || ok;",
4036
- " System.Threading.Thread.Sleep(120);",
4037
- " }",
4038
- ' note = "raised hwnd=" + dlg.ToInt64() + " ok=" + ok + " dlgEx=0x" + GetWindowLong(dlg, -20).ToString("X") + " ownerEx=0x" + GetWindowLong(owner, -20).ToString("X") + " isFg=" + (GetForegroundWindow() == dlg);',
4039
- " break;",
4040
- " }",
4041
- ' string dbg = System.Environment.GetEnvironmentVariable("EPOCH_PICK_DEBUG");',
4042
- " if (!string.IsNullOrEmpty(dbg)) {",
4043
- ' try { System.IO.File.AppendAllText(System.IO.Path.Combine(System.IO.Path.GetTempPath(), "epoch-pick-debug.log"), System.DateTime.Now.ToString("HH:mm:ss.fff") + " " + note + System.Environment.NewLine); } catch { }',
4044
- " }",
4045
- " });",
4046
- " t.IsBackground = true;",
4047
- " t.Start();",
4048
- "}",
4049
- "'@",
4050
- "[EpochPick.Win]::RaiseSoon($epochOwner.Handle)",
4051
- "} catch { }",
4052
- "$d = New-Object System.Windows.Forms.FolderBrowserDialog",
4053
- `$d.Description = ${asPowerShellString(prompt)}`,
4054
- "$d.ShowNewFolderButton = $true",
4055
- "$epochResult = $d.ShowDialog($epochOwner)",
4056
- "$epochOwner.Close()",
4057
- "if ($epochResult -ne [System.Windows.Forms.DialogResult]::OK) { exit 1 }",
4058
- "[Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($d.SelectedPath))"
4059
- ].join("\n");
4237
+ const preview = outcome.preview;
4238
+ sendJson(res, 200, {
4239
+ ok: true,
4240
+ source: preview.source,
4241
+ form: preview.form,
4242
+ skills: preview.skills.map(toWireEntry),
4243
+ issues: toWireIssues2(preview.issues),
4244
+ token: preview.token,
4245
+ detail: null,
4246
+ reason: null
4247
+ });
4060
4248
  }
4061
- function asAppleScriptString(text) {
4062
- return `"${text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
4249
+ async function importSkills(ctx, req, res, lang) {
4250
+ const body = await readJsonBody(req);
4251
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
4252
+ const payload = asRecord(body.value);
4253
+ const source = pathOf(payload);
4254
+ const token = typeof payload["token"] === "string" ? payload["token"] : "";
4255
+ const outcome = await ctx.runtime.skillWrite.import(source, token, lang);
4256
+ if (!outcome.ok) {
4257
+ return sendJson(res, 200, {
4258
+ ok: false,
4259
+ imported: [],
4260
+ skipped: toWireIssues2(outcome.issues),
4261
+ detail: outcome.detail,
4262
+ reason: outcome.reason
4263
+ });
4264
+ }
4265
+ sendJson(res, 200, {
4266
+ ok: outcome.imported.length > 0,
4267
+ imported: [...outcome.imported],
4268
+ skipped: toWireIssues2(outcome.skipped),
4269
+ detail: null,
4270
+ reason: null
4271
+ });
4063
4272
  }
4064
- function asPowerShellString(text) {
4065
- return `'${text.replace(/'/g, "''")}'`;
4273
+ // src/skill-remove.ts
4274
+ async function removeSkill(ctx, req, res, lang) {
4275
+ const body = await readJsonBody(req);
4276
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
4277
+ const payload = asRecord(body.value);
4278
+ const name = typeof payload["name"] === "string" ? payload["name"].trim() : "";
4279
+ const outcome = await ctx.runtime.skillWrite.remove(name, lang);
4280
+ if (!outcome.ok) {
4281
+ return sendJson(res, 200, {
4282
+ ok: false,
4283
+ name: "",
4284
+ path: null,
4285
+ detail: outcome.detail,
4286
+ reason: outcome.reason
4287
+ });
4288
+ }
4289
+ sendJson(res, 200, {
4290
+ ok: true,
4291
+ name: outcome.name,
4292
+ path: outcome.path,
4293
+ detail: null,
4294
+ reason: null
4295
+ });
4066
4296
  }
4067
- function decodeNativePick(platform, stdout) {
4068
- const raw = stdout.trim();
4069
- if (platform !== "win32") return raw;
4070
- return Buffer.from(raw, "base64").toString("utf-8").trim();
4297
+ function refusedByLan2(ctx, res, lang) {
4298
+ if (!ctx.lanExposed) return false;
4299
+ sendError(res, 403, "skill-stage-lan-exposed", t("web.skill_stage_lan_exposed", void 0, lang));
4300
+ return true;
4071
4301
  }
4072
- function isPickCanceled(platform, code, stderr) {
4073
- if (code !== 1) return false;
4074
- if (platform === "win32") return true;
4075
- return /-128|user canceled/i.test(stderr);
4302
+ async function stageSkillDrop(ctx, req, res, lang) {
4303
+ if (refusedByLan2(ctx, res, lang)) return;
4304
+ const raw = await readRawBody(req, PLUGIN_UPLOAD_BODY_LIMIT, lang);
4305
+ if (!raw.ok) return sendError(res, raw.status, "bad-body", raw.message);
4306
+ const outcome = await ctx.runtime.skillStage.stage(
4307
+ { name: nameParam3(req), data: raw.value },
4308
+ lang
4309
+ );
4310
+ sendJson(res, 200, {
4311
+ ok: outcome.ok,
4312
+ path: outcome.ok ? outcome.path : null,
4313
+ detail: outcome.ok ? null : outcome.detail,
4314
+ reason: outcome.ok ? null : outcome.reason
4315
+ });
4076
4316
  }
4077
- var inFlight = false;
4078
- async function pickNativeDirectory(opts) {
4079
- const platform = opts.platform ?? process.platform;
4080
- const candidates = nativePickerCommands(platform, opts.prompt);
4081
- if (!candidates) {
4082
- return { ok: false, reason: "unsupported", message: `unsupported platform: ${platform}` };
4083
- }
4084
- if (inFlight) {
4085
- return { ok: false, reason: "busy", message: "a directory picker is already open" };
4086
- }
4087
- const run = opts.runner ?? execFileRunner;
4088
- inFlight = true;
4317
+ function nameParam3(req) {
4089
4318
  try {
4090
- let last = "";
4091
- for (const [index, candidate] of candidates.entries()) {
4092
- const attempt = await run(candidate.command, candidate.args, opts.signal);
4093
- if (attempt.missing) {
4094
- last = attempt.message || `${candidate.command} not found`;
4095
- if (index < candidates.length - 1) continue;
4096
- return { ok: false, reason: "failed", message: last };
4097
- }
4098
- if (!attempt.code) {
4099
- const path = decodeNativePick(platform, attempt.stdout);
4100
- return path ? { ok: true, path } : { ok: false, reason: "failed", message: "picker returned an empty path" };
4101
- }
4102
- if (isPickCanceled(platform, attempt.code, attempt.stderr)) {
4103
- return { ok: true, canceled: true };
4104
- }
4105
- return { ok: false, reason: "failed", message: attempt.stderr.trim() || attempt.message };
4106
- }
4107
- return { ok: false, reason: "failed", message: last };
4108
- } finally {
4109
- inFlight = false;
4319
+ return new URL(req.url ?? "/", "http://127.0.0.1").searchParams.get("name") ?? "";
4320
+ } catch {
4321
+ return "";
4110
4322
  }
4111
4323
  }
4112
- var execFileRunner = (command, args, signal) => new Promise((done) => {
4113
- guardSpawn(
4114
- () => execFile(
4115
- command,
4116
- [...args],
4117
- {
4118
- ...signal ? { signal } : {},
4119
- encoding: "utf-8",
4120
- windowsHide: true,
4121
- maxBuffer: 1024 * 1024
4122
- },
4123
- (error, stdout, stderr) => {
4124
- const err = error;
4125
- done({
4126
- code: err?.code ?? 0,
4127
- stdout,
4128
- stderr,
4129
- missing: err?.code === "ENOENT",
4130
- message: err?.message ?? ""
4131
- });
4132
- }
4133
- ),
4134
- (err) => done({
4135
- code: err.code ?? 1,
4136
- stdout: "",
4137
- stderr: "",
4138
- missing: err.code === "ENOENT" || err.code === "ENOTDIR",
4139
- message: `${err.message} (command=${command})`
4140
- })
4141
- );
4142
- });
4143
- // src/workspace/dirs.ts
4144
- function browseWorkspaceDirs(ctx, res, rawUrl, lang) {
4145
- const query = new URL(rawUrl ?? "/", "http://127.0.0.1").searchParams;
4146
- const path = dirsQuery(query) ?? atQuery(query);
4147
- if (path === void 0) {
4148
- return sendJson(res, 200, workspaceAnchors(ctx.runtime.workspaces));
4324
+ // src/sse.ts
4325
+ var DEFAULT_HEARTBEAT_MS = 15e3;
4326
+ function parseLastEventId(req) {
4327
+ const raw = req.headers["last-event-id"];
4328
+ const value = Array.isArray(raw) ? raw[0] : raw;
4329
+ if (typeof value !== "string" || value.trim().length === 0) return void 0;
4330
+ const parsed = Number(value.trim());
4331
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : void 0;
4332
+ }
4333
+ var SseConnection = class {
4334
+ constructor(res, heartbeatMs = DEFAULT_HEARTBEAT_MS) {
4335
+ this.res = res;
4336
+ res.writeHead(200, {
4337
+ "Content-Type": "text/event-stream; charset=utf-8",
4338
+ "Cache-Control": "no-cache, no-transform",
4339
+ Connection: "keep-alive",
4340
+ "X-Accel-Buffering": "no",
4341
+ "Referrer-Policy": "no-referrer",
4342
+ "X-Content-Type-Options": "nosniff"
4343
+ });
4344
+ res.flushHeaders();
4345
+ this.heartbeat = setInterval(() => this.ping(), heartbeatMs);
4346
+ this.heartbeat.unref();
4149
4347
  }
4150
- const outcome = listWorkspaceDirs(ctx.runtime.workspaces, path, {
4151
- hidden: hiddenQuery(query)
4152
- });
4153
- if (outcome.ok) return sendJson(res, 200, outcome.view);
4154
- if (outcome.reason === "denied") {
4155
- return sendError(
4156
- res,
4157
- 403,
4158
- "workspace-denied",
4159
- t("web.dirs_denied", { path: outcome.path }, lang)
4160
- );
4348
+ res;
4349
+ heartbeat;
4350
+ closed = false;
4351
+ send(frame) {
4352
+ if (this.closed) return;
4353
+ const body = frame.seq > 0 ? `id: ${frame.seq}
4354
+ ` : "";
4355
+ this.res.write(`${body}data: ${JSON.stringify(frame)}
4356
+
4357
+ `);
4161
4358
  }
4162
- if (outcome.reason === "missing") {
4163
- return sendError(res, 404, "not-found", t("web.dirs_missing", { path: outcome.path }, lang));
4359
+ ping() {
4360
+ if (this.closed) return;
4361
+ this.res.write(": ping\n\n");
4164
4362
  }
4165
- sendError(
4166
- res,
4167
- 400,
4168
- "bad-workspace",
4169
- outcome.reason === "not-absolute" ? t("web.dirs_not_absolute", { path: outcome.path }, lang) : t("web.dirs_not_a_directory", { path: outcome.path }, lang)
4170
- );
4363
+ close() {
4364
+ if (this.closed) return;
4365
+ this.closed = true;
4366
+ clearInterval(this.heartbeat);
4367
+ this.res.end();
4368
+ }
4369
+ };
4370
+ var TASK_TAIL_BYTES = 8 * 1024;
4371
+ var LIVE_TASKS = {
4372
+ list: (sessionId) => listBackgroundTasks(sessionId),
4373
+ output: (sessionId, id, since) => backgroundTaskOutput(sessionId, id, since)
4374
+ };
4375
+ function toWireTask(sessionId, info, registry) {
4376
+ const since = Math.max(0, info.outputBytes - TASK_TAIL_BYTES);
4377
+ const chunk = registry.output(sessionId, info.id, since);
4378
+ if (!chunk) return { info, output: "", omittedBytes: info.outputBytes };
4379
+ return {
4380
+ info,
4381
+ output: chunk.output,
4382
+ omittedBytes: Math.max(0, chunk.nextCursor - chunk.output.length)
4383
+ };
4171
4384
  }
4172
- function dirsQuery(query) {
4173
- const value = query.get("path")?.trim();
4174
- return value ? value : void 0;
4385
+ function collectTasks(sessionId, registry) {
4386
+ return { tasks: registry.list(sessionId).map((info) => toWireTask(sessionId, info, registry)) };
4175
4387
  }
4176
- function atQuery(query) {
4177
- return query.get("at")?.trim().toLowerCase() === "home" ? workspaceHome() : void 0;
4388
+ function getTasks(ctx, res, sessionId, _lang, registry = LIVE_TASKS) {
4389
+ if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
4390
+ return sendUnknownSession(res, sessionId);
4391
+ }
4392
+ sendJson(res, 200, collectTasks(sessionId, registry));
4178
4393
  }
4179
- function hiddenQuery(query) {
4180
- const value = query.get("hidden")?.trim().toLowerCase();
4181
- return value === "1" || value === "true";
4394
+ var DIR_PAGE_LIMIT = 1e3;
4395
+ function workspaceHome() {
4396
+ return homedir();
4182
4397
  }
4183
- async function createWorkspaceDir(req, res, lang) {
4184
- const body = await readJsonBody(req);
4185
- if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
4186
- const payload = asRecord(body.value);
4187
- const parent = typeof payload["parent"] === "string" ? payload["parent"].trim() : "";
4188
- const name = typeof payload["name"] === "string" ? payload["name"].trim() : "";
4189
- if (parent.length === 0 || !isAbsolute(parent)) {
4190
- return sendError(
4191
- res,
4192
- 400,
4193
- "bad-workspace",
4194
- t("web.mkdir_parent_not_absolute", { parent }, lang)
4195
- );
4196
- }
4197
- if (!isSingleSegment(name)) {
4198
- return sendError(res, 400, "bad-workspace", t("web.mkdir_bad_name", { name }, lang));
4398
+ function workspaceAnchors(workspaces) {
4399
+ const seen = /* @__PURE__ */ new Set();
4400
+ const entries = [];
4401
+ const known = new Set(workspaces.known().map((one) => resolve(one.root)));
4402
+ const push = (raw, group) => {
4403
+ const path = resolve(raw);
4404
+ if (seen.has(path)) return;
4405
+ seen.add(path);
4406
+ entries.push({
4407
+ name: displayName2(path),
4408
+ path,
4409
+ known: known.has(path),
4410
+ readable: canList(path),
4411
+ group
4412
+ });
4413
+ };
4414
+ push(homedir(), "home");
4415
+ push(process.cwd(), "cwd");
4416
+ for (const one of workspaces.known()) push(one.root, "known");
4417
+ return {
4418
+ path: null,
4419
+ parent: null,
4420
+ entries,
4421
+ omitted: 0,
4422
+ hidden: 0
4423
+ };
4424
+ }
4425
+ function listWorkspaceDirs(workspaces, raw, opts = {}) {
4426
+ if (raw.length === 0 || !isAbsolute(raw)) {
4427
+ return { ok: false, reason: "not-absolute", path: raw };
4199
4428
  }
4200
- const path = resolve(parent, name);
4429
+ const path = resolve(raw);
4430
+ let dirents;
4201
4431
  try {
4202
- mkdirSync(path);
4432
+ dirents = readdirSync(path, { withFileTypes: true });
4203
4433
  } catch (cause) {
4204
- return sendError(res, ...mkdirFailure(cause, path, lang));
4434
+ return { ok: false, reason: readdirFailure(cause), path };
4435
+ }
4436
+ const known = new Set(workspaces.known().map((one) => resolve(one.root)));
4437
+ const kept = [];
4438
+ let omitted = 0;
4439
+ let hidden = 0;
4440
+ for (const dirent of dirents) {
4441
+ if (!dirent.isDirectory()) continue;
4442
+ if (!opts.hidden && dirent.name.startsWith(".")) {
4443
+ hidden += 1;
4444
+ continue;
4445
+ }
4446
+ if (kept.length >= DIR_PAGE_LIMIT) {
4447
+ omitted += 1;
4448
+ continue;
4449
+ }
4450
+ const child = resolve(path, dirent.name);
4451
+ kept.push({
4452
+ name: dirent.name,
4453
+ path: child,
4454
+ known: known.has(child),
4455
+ readable: canList(child),
4456
+ group: "child"
4457
+ });
4205
4458
  }
4206
- sendJson(res, 200, {
4207
- entry: {
4208
- name,
4459
+ return {
4460
+ ok: true,
4461
+ view: {
4209
4462
  path,
4210
- readable: true,
4211
- known: false,
4212
- group: "child"
4463
+ parent: parentOf(path),
4464
+ entries: kept,
4465
+ omitted,
4466
+ hidden
4213
4467
  }
4214
- });
4215
- }
4216
- async function pickWorkspaceDir(ctx, req, res, lang) {
4217
- if (!ctx.nativeDirPicker) {
4218
- return sendError(res, 403, "native-picker-unavailable", t("web.pick_unavailable", {}, lang));
4219
- }
4220
- const abort = new AbortController();
4221
- req.on("close", () => abort.abort());
4222
- const outcome = await pickNativeDirectory({
4223
- prompt: t("web.pick_prompt", {}, lang),
4224
- signal: abort.signal
4225
- });
4226
- if (res.writableEnded || abort.signal.aborted) return;
4227
- if (outcome.ok) {
4228
- return sendJson(
4229
- res,
4230
- 200,
4231
- outcome.path === void 0 ? { canceled: true } : { path: outcome.path }
4232
- );
4233
- }
4234
- if (outcome.reason === "busy") {
4235
- return sendError(res, 409, "native-picker-busy", t("web.pick_busy", {}, lang));
4236
- }
4237
- sendError(
4238
- res,
4239
- 500,
4240
- "native-picker-failed",
4241
- t("web.pick_failed", { detail: outcome.message }, lang)
4242
- );
4243
- }
4244
- function isSingleSegment(name) {
4245
- if (name.length === 0) return false;
4246
- if (name === "." || name === "..") return false;
4247
- return !name.includes("/") && !name.includes(sep);
4468
+ };
4248
4469
  }
4249
- function mkdirFailure(cause, path, lang) {
4470
+ function readdirFailure(cause) {
4250
4471
  const code = cause?.code;
4251
- if (code === "EEXIST") return [409, "workspace-exists", t("web.mkdir_exists", { path }, lang)];
4252
- if (code === "EACCES" || code === "EPERM") {
4253
- return [403, "workspace-denied", t("web.mkdir_denied", { path }, lang)];
4254
- }
4255
- if (code === "ENOENT") return [404, "not-found", t("web.mkdir_parent_missing", { path }, lang)];
4256
- return [500, "internal", t("web.mkdir_failed", { path }, lang)];
4257
- }
4258
- var MAX_TEXT_BYTES = 2 * 1024 * 1024;
4259
- var VIEWABLE = /* @__PURE__ */ new Map([
4260
- [".png", { kind: "image", mime: "image/png" }],
4261
- [".jpg", { kind: "image", mime: "image/jpeg" }],
4262
- [".jpeg", { kind: "image", mime: "image/jpeg" }],
4263
- [".gif", { kind: "image", mime: "image/gif" }],
4264
- [".webp", { kind: "image", mime: "image/webp" }],
4265
- [".bmp", { kind: "image", mime: "image/bmp" }],
4266
- [".ico", { kind: "image", mime: "image/x-icon" }],
4267
- [".avif", { kind: "image", mime: "image/avif" }],
4268
- [".pdf", { kind: "pdf", mime: "application/pdf" }],
4269
- [".mp3", { kind: "audio", mime: "audio/mpeg" }],
4270
- [".wav", { kind: "audio", mime: "audio/wav" }],
4271
- [".ogg", { kind: "audio", mime: "audio/ogg" }],
4272
- [".m4a", { kind: "audio", mime: "audio/mp4" }],
4273
- [".flac", { kind: "audio", mime: "audio/flac" }],
4274
- [".mp4", { kind: "video", mime: "video/mp4" }],
4275
- [".webm", { kind: "video", mime: "video/webm" }],
4276
- [".mov", { kind: "video", mime: "video/quicktime" }]
4277
- ]);
4278
- function kindOf(stat3) {
4279
- if (stat3.kind === "text") return "text";
4280
- return VIEWABLE.get(extname(stat3.rel).toLowerCase())?.kind ?? "opaque";
4472
+ if (code === "EACCES" || code === "EPERM") return "denied";
4473
+ if (code === "ENOTDIR") return "not-a-directory";
4474
+ return "missing";
4281
4475
  }
4282
- function toWireRefusal(reason) {
4283
- return reason;
4476
+ function canList(path) {
4477
+ try {
4478
+ accessSync(path, constants.R_OK | constants.X_OK);
4479
+ return true;
4480
+ } catch {
4481
+ return false;
4482
+ }
4284
4483
  }
4285
- function statusFor(refusal) {
4286
- if (refusal === "denied") return 403;
4287
- if (refusal === "missing") return 404;
4288
- if (refusal === "too-large") return 413;
4289
- return refusal === "not-a-file" ? 400 : 503;
4484
+ function parentOf(path) {
4485
+ const up = dirname(path);
4486
+ return up === path ? null : up;
4290
4487
  }
4291
- function describeRefusal(refusal, lang) {
4292
- if (refusal === "denied") return t("web.file_view_denied", void 0, lang);
4293
- if (refusal === "missing") return t("web.file_view_missing", void 0, lang);
4294
- if (refusal === "not-a-file") return t("web.file_view_not_a_file", void 0, lang);
4295
- if (refusal === "too-large") {
4296
- return t("web.file_view_too_large", { max: String(MAX_TEXT_BYTES) }, lang);
4297
- }
4298
- return t("web.file_view_unreadable", void 0, lang);
4488
+ function displayName2(path) {
4489
+ return basename(path) || path;
4299
4490
  }
4300
- function resolveViewRoot(ctx, res, sessionId, lang) {
4301
- if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
4302
- sendUnknownSession(res, sessionId);
4303
- return null;
4304
- }
4305
- const root = workspaceRootOf(ctx, sessionId);
4306
- if (!root) {
4307
- sendError(res, 409, "no-workspace", t("web.file_view_no_workspace", void 0, lang));
4308
- return null;
4309
- }
4310
- return root;
4491
+ function present(value) {
4492
+ return value !== void 0 && value !== "";
4311
4493
  }
4312
- function singlePath(res, rawUrl, lang) {
4313
- const params = new URLSearchParams((rawUrl ?? "").split("?")[1] ?? "");
4314
- const path = params.get("path")?.trim();
4315
- if (!path) {
4316
- sendError(res, 400, "missing-path", t("web.file_view_missing_path", void 0, lang));
4317
- return null;
4318
- }
4319
- return path;
4494
+ function resolveNativeDirPicker(facts) {
4495
+ return isSameMachine(facts);
4320
4496
  }
4321
- function statFiles(ctx, res, sessionId, rawUrl, lang) {
4322
- const root = resolveViewRoot(ctx, res, sessionId, lang);
4323
- if (root === null) return;
4324
- const params = new URLSearchParams((rawUrl ?? "").split("?")[1] ?? "");
4325
- const asked = params.getAll("path").filter((p) => p.trim() !== "");
4326
- const taken = asked.slice(0, WIRE_FILE_STAT_MAX);
4327
- const files = taken.map((path) => {
4328
- const stat3 = ctx.runtime.workspaceFiles.statFile(path, root);
4329
- if (!stat3.ok) return { path, ok: false, refusal: toWireRefusal(stat3.reason) };
4330
- return {
4331
- path,
4332
- ok: true,
4333
- kind: kindOf(stat3),
4334
- bytes: stat3.bytes,
4335
- mtimeMs: stat3.mtimeMs,
4336
- openable: ctx.sameMachine && isSystemOpenable(stat3.rel)
4337
- };
4338
- });
4339
- sendJson(res, 200, {
4340
- files,
4341
- overLimit: asked.length - taken.length,
4342
- root
4343
- });
4497
+ function isSameMachine(facts) {
4498
+ if (facts.lanExposed) return false;
4499
+ if (present(facts.env.SSH_CONNECTION) || present(facts.env.SSH_TTY)) return false;
4500
+ return facts.platform === "darwin" || facts.platform === "win32";
4344
4501
  }
4345
- function readFileText(ctx, res, sessionId, rawUrl, lang) {
4346
- const root = resolveViewRoot(ctx, res, sessionId, lang);
4347
- if (root === null) return;
4348
- const path = singlePath(res, rawUrl, lang);
4349
- if (path === null) return;
4350
- const stat3 = ctx.runtime.workspaceFiles.statFile(path, root);
4351
- if (!stat3.ok) {
4352
- const refusal = toWireRefusal(stat3.reason);
4353
- return sendError(res, statusFor(refusal), refusal, describeRefusal(refusal, lang));
4354
- }
4355
- if (stat3.kind !== "text") {
4356
- return sendError(res, 415, "not-text", t("web.file_view_not_text", void 0, lang));
4357
- }
4358
- if (stat3.bytes > MAX_TEXT_BYTES) {
4359
- return sendError(res, 413, "too-large", describeRefusal("too-large", lang));
4502
+ function nativePickerCommands(platform, prompt) {
4503
+ if (platform === "darwin") {
4504
+ return [
4505
+ {
4506
+ command: "osascript",
4507
+ args: [
4508
+ "-e",
4509
+ `set epochChosenFolder to choose folder with prompt ${asAppleScriptString(prompt)}`,
4510
+ "-e",
4511
+ "POSIX path of epochChosenFolder"
4512
+ ]
4513
+ }
4514
+ ];
4360
4515
  }
4361
- const out = ctx.runtime.workspaceFiles.readFile(stat3.rel, root);
4362
- if (!out.ok) {
4363
- const refusal = out.reason === "binary" ? "unreadable" : out.reason;
4364
- return sendError(res, statusFor(refusal), refusal, describeRefusal(refusal, lang));
4516
+ if (platform === "win32") {
4517
+ const script = win32PickScript(prompt);
4518
+ return [
4519
+ { command: "pwsh", args: ["-NoProfile", "-STA", "-Command", script] },
4520
+ { command: "powershell.exe", args: ["-NoProfile", "-STA", "-Command", script] }
4521
+ ];
4365
4522
  }
4366
- sendJson(res, 200, {
4367
- path,
4368
- text: out.text,
4369
- bytes: out.bytes
4370
- });
4523
+ return null;
4524
+ }
4525
+ function win32PickScript(prompt) {
4526
+ return [
4527
+ "Add-Type -AssemblyName System.Windows.Forms",
4528
+ "Add-Type -AssemblyName System.Drawing",
4529
+ "$epochOwner = New-Object System.Windows.Forms.Form",
4530
+ "$epochOwner.FormBorderStyle = 'None'",
4531
+ "$epochOwner.ShowInTaskbar = $false",
4532
+ "$epochOwner.TopMost = $true",
4533
+ "$epochOwner.StartPosition = 'Manual'",
4534
+ "$epochOwner.Size = New-Object System.Drawing.Size 1, 1",
4535
+ "$epochOwner.Location = New-Object System.Drawing.Point -32000, -32000",
4536
+ "$epochOwner.Show()",
4537
+ "$epochOwner.Activate()",
4538
+ "try {",
4539
+ "Add-Type -Namespace EpochPick -Name Win -MemberDefinition @'",
4540
+ '[DllImport("user32.dll")] public static extern IntPtr GetWindow(IntPtr h, uint cmd);',
4541
+ '[DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr h, IntPtr after, int x, int y, int cx, int cy, uint flags);',
4542
+ '[DllImport("user32.dll")] public static extern void SwitchToThisWindow(IntPtr h, bool useAltTab);',
4543
+ '[DllImport("user32.dll")] public static extern int GetWindowLong(IntPtr h, int i);',
4544
+ '[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();',
4545
+ '[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, IntPtr pid);',
4546
+ '[DllImport("user32.dll")] public static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach);',
4547
+ '[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);',
4548
+ '[DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr h);',
4549
+ '[DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();',
4550
+ "static bool Raise(IntPtr h) {",
4551
+ " IntPtr fg = GetForegroundWindow();",
4552
+ " uint fgThread = GetWindowThreadProcessId(fg, IntPtr.Zero);",
4553
+ " uint me = GetCurrentThreadId();",
4554
+ " bool attached = false;",
4555
+ " if (fgThread != 0 && fgThread != me) { attached = AttachThreadInput(me, fgThread, true); }",
4556
+ " bool okPos = SetWindowPos(h, new IntPtr(-1), 0, 0, 0, 0, 3);",
4557
+ " BringWindowToTop(h);",
4558
+ " bool okFg = SetForegroundWindow(h);",
4559
+ " SwitchToThisWindow(h, true);",
4560
+ " if (attached) { AttachThreadInput(me, fgThread, false); }",
4561
+ " return okPos && okFg;",
4562
+ "}",
4563
+ "public static void RaiseSoon(IntPtr owner) {",
4564
+ " System.Threading.Thread t = new System.Threading.Thread(delegate() {",
4565
+ ' string note = "dialog window never appeared";',
4566
+ " for (int i = 0; i < 400; i++) {",
4567
+ " System.Threading.Thread.Sleep(30);",
4568
+ " IntPtr dlg = GetWindow(owner, 6);",
4569
+ " if (dlg == IntPtr.Zero) { continue; }",
4570
+ " bool ok = false;",
4571
+ " for (int k = 0; k < 4; k++) {",
4572
+ " Raise(owner);",
4573
+ " ok = Raise(dlg) || ok;",
4574
+ " System.Threading.Thread.Sleep(120);",
4575
+ " }",
4576
+ ' note = "raised hwnd=" + dlg.ToInt64() + " ok=" + ok + " dlgEx=0x" + GetWindowLong(dlg, -20).ToString("X") + " ownerEx=0x" + GetWindowLong(owner, -20).ToString("X") + " isFg=" + (GetForegroundWindow() == dlg);',
4577
+ " break;",
4578
+ " }",
4579
+ ' string dbg = System.Environment.GetEnvironmentVariable("EPOCH_PICK_DEBUG");',
4580
+ " if (!string.IsNullOrEmpty(dbg)) {",
4581
+ ' try { System.IO.File.AppendAllText(System.IO.Path.Combine(System.IO.Path.GetTempPath(), "epoch-pick-debug.log"), System.DateTime.Now.ToString("HH:mm:ss.fff") + " " + note + System.Environment.NewLine); } catch { }',
4582
+ " }",
4583
+ " });",
4584
+ " t.IsBackground = true;",
4585
+ " t.Start();",
4586
+ "}",
4587
+ "'@",
4588
+ "[EpochPick.Win]::RaiseSoon($epochOwner.Handle)",
4589
+ "} catch { }",
4590
+ "$d = New-Object System.Windows.Forms.FolderBrowserDialog",
4591
+ `$d.Description = ${asPowerShellString(prompt)}`,
4592
+ "$d.ShowNewFolderButton = $true",
4593
+ "$epochResult = $d.ShowDialog($epochOwner)",
4594
+ "$epochOwner.Close()",
4595
+ "if ($epochResult -ne [System.Windows.Forms.DialogResult]::OK) { exit 1 }",
4596
+ "[Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($d.SelectedPath))"
4597
+ ].join("\n");
4371
4598
  }
4372
- function readFileBytes(ctx, res, sessionId, rawUrl, headOnly, lang) {
4373
- const root = resolveViewRoot(ctx, res, sessionId, lang);
4374
- if (root === null) return;
4375
- const path = singlePath(res, rawUrl, lang);
4376
- if (path === null) return;
4377
- const stat3 = ctx.runtime.workspaceFiles.statFile(path, root);
4378
- if (!stat3.ok) {
4379
- const refusal = toWireRefusal(stat3.reason);
4380
- return sendError(res, statusFor(refusal), refusal, describeRefusal(refusal, lang));
4381
- }
4382
- const known = VIEWABLE.get(extname(stat3.rel).toLowerCase());
4383
- const inline = known !== void 0 && stat3.kind === "binary";
4384
- const headers = {
4385
- "Content-Type": inline ? known.mime : "application/octet-stream",
4386
- "X-Content-Type-Options": "nosniff",
4387
- "Content-Disposition": inline ? "inline" : `attachment; filename*=UTF-8''${encodeURIComponent(basenameOf(stat3.rel))}`
4388
- };
4389
- if (inline && known.kind === "pdf") headers["Content-Security-Policy"] = "sandbox";
4390
- if (headOnly) {
4391
- return sendEmpty(res, 200, {
4392
- ...headers,
4393
- "Content-Length": String(stat3.bytes)
4394
- });
4395
- }
4396
- sendFile(res, stat3.absPath, headers);
4599
+ function asAppleScriptString(text) {
4600
+ return `"${text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
4397
4601
  }
4398
- function basenameOf(rel) {
4399
- const cut = rel.lastIndexOf("/");
4400
- return cut < 0 ? rel : rel.slice(cut + 1);
4602
+ function asPowerShellString(text) {
4603
+ return `'${text.replace(/'/g, "''")}'`;
4401
4604
  }
4402
- // src/workspace/file-open.ts
4403
- function describeOpenRefusal(refusal, lang) {
4404
- if (refusal === "extension-not-allowed") {
4405
- return t("web.file_open_extension_not_allowed", void 0, lang);
4406
- }
4407
- if (refusal === "not-same-machine") return t("web.file_open_not_same_machine", void 0, lang);
4408
- if (refusal === "launch-failed") return t("web.file_open_launch_failed", void 0, lang);
4409
- return describeRefusal(refusal, lang);
4605
+ function decodeNativePick(platform, stdout) {
4606
+ const raw = stdout.trim();
4607
+ if (platform !== "win32") return raw;
4608
+ return Buffer.from(raw, "base64").toString("utf-8").trim();
4410
4609
  }
4411
- function refuse(res, refusal, lang) {
4412
- sendJson(res, 200, {
4413
- ok: false,
4414
- refusal,
4415
- message: describeOpenRefusal(refusal, lang)
4416
- });
4610
+ function isPickCanceled(platform, code, stderr) {
4611
+ if (code !== 1) return false;
4612
+ if (platform === "win32") return true;
4613
+ return /-128|user canceled/i.test(stderr);
4417
4614
  }
4418
- async function openFileWithSystemApp(ctx, req, res, sessionId, lang) {
4419
- if (!ctx.sameMachine) {
4420
- return sendError(res, 403, "not-same-machine", describeOpenRefusal("not-same-machine", lang));
4615
+ var inFlight = false;
4616
+ async function pickNativeDirectory(opts) {
4617
+ const platform = opts.platform ?? process.platform;
4618
+ const candidates = nativePickerCommands(platform, opts.prompt);
4619
+ if (!candidates) {
4620
+ return { ok: false, reason: "unsupported", message: `unsupported platform: ${platform}` };
4421
4621
  }
4422
- const root = resolveViewRoot(ctx, res, sessionId, lang);
4423
- if (root === null) return;
4424
- const body = await readJsonBody(req);
4425
- if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
4426
- const payload = asRecord(body.value);
4427
- const path = typeof payload["path"] === "string" ? payload["path"].trim() : "";
4428
- if (path === "") {
4429
- return sendError(res, 400, "missing-path", t("web.file_view_missing_path", void 0, lang));
4622
+ if (inFlight) {
4623
+ return { ok: false, reason: "busy", message: "a directory picker is already open" };
4430
4624
  }
4431
- const plan = ctx.runtime.workspaceFiles.planSystemOpen(path, root);
4432
- if (!plan.ok) return refuse(res, plan.reason, lang);
4625
+ const run = opts.runner ?? execFileRunner;
4626
+ inFlight = true;
4433
4627
  try {
4434
- const child = spawn(plan.command, [...plan.args], { stdio: "ignore", detached: true });
4435
- await new Promise((done, fail) => {
4436
- child.on("error", fail);
4437
- child.on("spawn", () => {
4438
- child.unref();
4439
- done();
4440
- });
4441
- });
4442
- } catch {
4443
- return refuse(res, "launch-failed", lang);
4628
+ let last = "";
4629
+ for (const [index, candidate] of candidates.entries()) {
4630
+ const attempt = await run(candidate.command, candidate.args, opts.signal);
4631
+ if (attempt.missing) {
4632
+ last = attempt.message || `${candidate.command} not found`;
4633
+ if (index < candidates.length - 1) continue;
4634
+ return { ok: false, reason: "failed", message: last };
4635
+ }
4636
+ if (!attempt.code) {
4637
+ const path = decodeNativePick(platform, attempt.stdout);
4638
+ return path ? { ok: true, path } : { ok: false, reason: "failed", message: "picker returned an empty path" };
4639
+ }
4640
+ if (isPickCanceled(platform, attempt.code, attempt.stderr)) {
4641
+ return { ok: true, canceled: true };
4642
+ }
4643
+ return { ok: false, reason: "failed", message: attempt.stderr.trim() || attempt.message };
4644
+ }
4645
+ return { ok: false, reason: "failed", message: last };
4646
+ } finally {
4647
+ inFlight = false;
4444
4648
  }
4445
- sendJson(res, 200, { ok: true });
4446
4649
  }
4447
- async function forgetWorkspace(ctx, sessionId, req, res, lang) {
4448
- const body = await readJsonBody(req, void 0, lang);
4449
- if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
4450
- if (!conformsToWire(body.value, WIRE_FORGET_WORKSPACE_FIELDS)) {
4451
- return sendError(res, 400, "bad-request", t("web.forget_bad_request", {}, lang));
4452
- }
4453
- const { root } = body.value;
4454
- const workspaces = ctx.runtime.workspaces;
4455
- if (!workspaces.known().some((w) => w.root === root)) {
4456
- return sendError(res, 404, "unknown-workspace", t("web.forget_unknown", {}, lang));
4457
- }
4458
- const current = workspaces.of(sessionId)?.workspace.root;
4459
- if (current !== void 0 && current === root) {
4460
- return sendError(res, 409, "workspace-in-use", t("web.forget_in_use", {}, lang));
4650
+ var execFileRunner = (command, args, signal) => new Promise((done) => {
4651
+ guardSpawn(
4652
+ () => execFile(
4653
+ command,
4654
+ [...args],
4655
+ {
4656
+ ...signal ? { signal } : {},
4657
+ encoding: "utf-8",
4658
+ windowsHide: true,
4659
+ maxBuffer: 1024 * 1024
4660
+ },
4661
+ (error, stdout, stderr) => {
4662
+ const err = error;
4663
+ done({
4664
+ code: err?.code ?? 0,
4665
+ stdout,
4666
+ stderr,
4667
+ missing: err?.code === "ENOENT",
4668
+ message: err?.message ?? ""
4669
+ });
4670
+ }
4671
+ ),
4672
+ (err) => done({
4673
+ code: err.code ?? 1,
4674
+ stdout: "",
4675
+ stderr: "",
4676
+ missing: err.code === "ENOENT" || err.code === "ENOTDIR",
4677
+ message: `${err.message} (command=${command})`
4678
+ })
4679
+ );
4680
+ });
4681
+ // src/workspace/dirs.ts
4682
+ function browseWorkspaceDirs(ctx, res, rawUrl, lang) {
4683
+ const query = new URL(rawUrl ?? "/", "http://127.0.0.1").searchParams;
4684
+ const path = dirsQuery(query) ?? atQuery(query);
4685
+ if (path === void 0) {
4686
+ return sendJson(res, 200, workspaceAnchors(ctx.runtime.workspaces));
4461
4687
  }
4462
- workspaces.forget(root);
4463
- sendJson(res, 200, {
4464
- known: toWireTrustList(workspaces.known(), workspaces, current ?? null)
4465
- });
4466
- }
4467
- var GIT_TIMEOUT_MS = 1e4;
4468
- var GIT_MAX_BUFFER = 16 * 1024 * 1024;
4469
- function runGit(cwd, args) {
4470
- if (!canProbeGit()) return Promise.resolve({ ok: false, error: "git unavailable" });
4471
- return new Promise((resolve4) => {
4472
- guardSpawn(
4473
- () => execFile(
4474
- "git",
4475
- [...args],
4476
- { cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BUFFER, encoding: "buffer" },
4477
- (err, stdout) => {
4478
- if (err) return resolve4({ ok: false, error: err.message });
4479
- resolve4({ ok: true, stdout });
4480
- }
4481
- ),
4482
- (err) => resolve4({ ok: false, error: `${err.message} (cwd=${cwd})` })
4483
- );
4688
+ const outcome = listWorkspaceDirs(ctx.runtime.workspaces, path, {
4689
+ hidden: hiddenQuery(query)
4484
4690
  });
4485
- }
4486
- async function repoRoot(cwd) {
4487
- const result = await runGit(cwd, ["rev-parse", "--show-toplevel"]);
4488
- if (!result.ok) return null;
4489
- const root = result.stdout.toString("utf-8").trim();
4490
- return root.length > 0 ? root : null;
4491
- }
4492
- async function hasHead(root) {
4493
- return (await runGit(root, ["rev-parse", "--verify", "--quiet", "HEAD"])).ok;
4494
- }
4495
- var NULL_SHA = /^0+$/;
4496
- async function rawChanges(root) {
4497
- const result = await runGit(root, ["diff", "--raw", "-z", "HEAD", "--"]);
4498
- if (!result.ok) return [];
4499
- return parseRawZ(result.stdout.toString("utf-8"));
4500
- }
4501
- function parseRawZ(text) {
4502
- const tokens = text.split("\0");
4503
- const changes = [];
4504
- let i = 0;
4505
- while (i < tokens.length) {
4506
- const meta = tokens[i];
4507
- if (meta === void 0 || !meta.startsWith(":")) {
4508
- i += 1;
4509
- continue;
4510
- }
4511
- const parts = meta.slice(1).split(" ");
4512
- const oldSha = parts[2] ?? "";
4513
- const status = (parts[4] ?? "").trim();
4514
- const renamed = status.startsWith("R") || status.startsWith("C");
4515
- const first = tokens[i + 1];
4516
- const second = renamed ? tokens[i + 2] : void 0;
4517
- i += renamed ? 3 : 2;
4518
- if (first === void 0 || renamed && second === void 0) break;
4519
- const path = renamed ? second : first;
4520
- changes.push({
4521
- status: status.charAt(0),
4522
- path,
4523
- ...renamed ? { oldPath: first } : {},
4524
- oldSha: NULL_SHA.test(oldSha) ? null : oldSha
4525
- });
4691
+ if (outcome.ok) return sendJson(res, 200, outcome.view);
4692
+ if (outcome.reason === "denied") {
4693
+ return sendError(
4694
+ res,
4695
+ 403,
4696
+ "workspace-denied",
4697
+ t("web.dirs_denied", { path: outcome.path }, lang)
4698
+ );
4526
4699
  }
4527
- return changes;
4528
- }
4529
- async function treeBlobSizes(root) {
4530
- const sizes = /* @__PURE__ */ new Map();
4531
- const result = await runGit(root, ["ls-tree", "-r", "-l", "-z", "HEAD"]);
4532
- if (!result.ok) return sizes;
4533
- for (const record of splitZ(result.stdout.toString("utf-8"))) {
4534
- const tab = record.indexOf(" ");
4535
- if (tab < 0) continue;
4536
- const columns = record.slice(0, tab).split(/\s+/).filter(Boolean);
4537
- const sha = columns[2];
4538
- const size = Number(columns[3]);
4539
- 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));
4540
4702
  }
4541
- return sizes;
4542
- }
4543
- async function untrackedFiles(root) {
4544
- const result = await runGit(root, ["ls-files", "-z", "--others", "--exclude-standard"]);
4545
- if (!result.ok) return [];
4546
- return splitZ(result.stdout.toString("utf-8"));
4547
- }
4548
- async function trackedFiles(root) {
4549
- const result = await runGit(root, ["ls-files", "-z"]);
4550
- if (!result.ok) return [];
4551
- 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
+ );
4552
4709
  }
4553
- async function catFileBlob(root, sha) {
4554
- const result = await runGit(root, ["cat-file", "blob", sha]);
4555
- return result.ok ? result.stdout : null;
4710
+ function dirsQuery(query) {
4711
+ const value = query.get("path")?.trim();
4712
+ return value ? value : void 0;
4556
4713
  }
4557
- function splitZ(text) {
4558
- return text.split("\0").filter((s) => s.length > 0);
4714
+ function atQuery(query) {
4715
+ return query.get("at")?.trim().toLowerCase() === "home" ? workspaceHome() : void 0;
4559
4716
  }
4560
- // src/workspace/diff.ts
4561
- var MAX_DIFF_BYTES = 512 * 1024;
4562
- var MAX_DIFF_FILES = 200;
4563
- var BINARY_SNIFF_BYTES = 8e3;
4564
- var ABSENT = { kind: "absent" };
4565
- async function collectWorkspaceDiff(opts) {
4566
- const root = await repoRoot(opts.workDir);
4567
- 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";
4568
4720
  }
4569
- async function gitDiff(root) {
4570
- const wanted = await collectGitPaths(root);
4571
- const notes = [];
4572
- const truncated = wanted.length > MAX_DIFF_FILES;
4573
- const picked = truncated ? wanted.slice(0, MAX_DIFF_FILES) : wanted;
4574
- if (truncated) notes.push("truncated");
4575
- if (wanted.length === 0) notes.push("no-changes");
4576
- const sizes = picked.some((p) => p.oldSha !== null) ? await treeBlobSizes(root) : /* @__PURE__ */ new Map();
4577
- const files = [];
4578
- for (const item of picked) {
4579
- const old = await gitSide(root, item.oldSha, sizes);
4580
- const fresh = await worktreeSide(join(root, ...item.path.split("/")));
4581
- 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
+ );
4582
4734
  }
4583
- return { source: "git", root, files, truncated, notes };
4584
- }
4585
- async function collectGitPaths(root) {
4586
- const byPath = /* @__PURE__ */ new Map();
4587
- if (await hasHead(root)) {
4588
- for (const change of await rawChanges(root)) {
4589
- if (change.oldPath !== void 0) {
4590
- byPath.set(change.oldPath, { path: change.oldPath, oldSha: change.oldSha });
4591
- }
4592
- byPath.set(change.path, {
4593
- path: change.path,
4594
- oldSha: change.oldPath === void 0 ? change.oldSha : null
4595
- });
4596
- }
4597
- } else {
4598
- 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));
4599
4737
  }
4600
- for (const path of await untrackedFiles(root)) {
4601
- 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));
4602
4743
  }
4603
- return [...byPath.values()].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
4604
- }
4605
- async function gitSide(root, sha, sizes) {
4606
- if (sha === null) return ABSENT;
4607
- const known = sizes.get(sha);
4608
- if (known !== void 0 && known > MAX_DIFF_BYTES) return { kind: "oversize", bytes: known };
4609
- const data = await catFileBlob(root, sha);
4610
- if (data === null) return { kind: "oversize", bytes: known ?? -1 };
4611
- 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
+ });
4612
4753
  }
4613
- async function checkpointDiff(opts) {
4614
- const notes = ["not-a-git-repo"];
4615
- const paths = await checkpointPaths(opts.checkpoints);
4616
- if (paths.length === 0) {
4617
- notes.push(opts.checkpoints === null ? "no-workspace-history" : "no-changes");
4618
- 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));
4619
4757
  }
4620
- const truncated = paths.length > MAX_DIFF_FILES;
4621
- const picked = truncated ? paths.slice(0, MAX_DIFF_FILES) : paths;
4622
- if (truncated) notes.push("truncated");
4623
- const files = [];
4624
- for (const absolute of picked) {
4625
- const fresh = await worktreeSide(absolute);
4626
- files.push(
4627
- describe3(displayPath(opts.workDir, absolute), ABSENT, fresh, {
4628
- status: fresh.kind === "absent" ? "deleted" : "modified",
4629
- omitted: "no-snapshot"
4630
- })
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 }
4631
4770
  );
4632
4771
  }
4633
- return { source: "checkpoints", root: opts.workDir, files, truncated, notes };
4634
- }
4635
- async function checkpointPaths(checkpoints) {
4636
- if (!checkpoints) return [];
4637
- const paths = /* @__PURE__ */ new Set();
4638
- try {
4639
- for (const summary of await checkpoints.list()) {
4640
- const preview = await checkpoints.preview(summary.turnIndex);
4641
- if (!preview) continue;
4642
- for (const plan of [...preview.files, ...preview.conflicts]) {
4643
- if (plan.action !== "skip") paths.add(plan.path);
4644
- }
4645
- }
4646
- } catch {
4772
+ if (outcome.reason === "busy") {
4773
+ return sendError(res, 409, "native-picker-busy", t("web.pick_busy", {}, lang));
4647
4774
  }
4648
- return [...paths].sort();
4775
+ sendError(
4776
+ res,
4777
+ 500,
4778
+ "native-picker-failed",
4779
+ t("web.pick_failed", { detail: outcome.message }, lang)
4780
+ );
4649
4781
  }
4650
- function displayPath(workDir, absolute) {
4651
- const rel = relative(workDir, absolute);
4652
- if (rel.length === 0 || rel.startsWith("..") || isAbsolute(rel)) return absolute;
4653
- 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);
4654
4786
  }
4655
- async function worktreeSide(absolute) {
4656
- try {
4657
- const info = await stat(absolute);
4658
- if (!info.isFile()) return ABSENT;
4659
- if (info.size > MAX_DIFF_BYTES) return { kind: "oversize", bytes: info.size };
4660
- return { kind: "content", data: await readFile(absolute) };
4661
- } catch {
4662
- 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)];
4663
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)];
4664
4795
  }
4665
- function describe3(path, old, fresh, override = {}) {
4666
- const status = override.status ?? (old.kind === "absent" ? "added" : fresh.kind === "absent" ? "deleted" : "modified");
4667
- const oldBytes = bytesOf(old);
4668
- const newBytes = bytesOf(fresh);
4669
- const hard = omitReason(old, fresh);
4670
- if (hard !== void 0) {
4671
- return { path, status, oldContent: null, newContent: null, oldBytes, newBytes, omitted: hard };
4672
- }
4673
- return {
4674
- path,
4675
- status,
4676
- oldContent: textOf(old),
4677
- newContent: textOf(fresh),
4678
- oldBytes,
4679
- newBytes,
4680
- ...override.omitted === void 0 ? {} : { omitted: override.omitted }
4681
- };
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";
4682
4819
  }
4683
- function bytesOf(side) {
4684
- if (side.kind === "absent") return null;
4685
- if (side.kind === "oversize") return side.bytes < 0 ? null : side.bytes;
4686
- 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);
4687
4837
  }
4688
- function textOf(side) {
4689
- 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;
4690
4849
  }
4691
- function omitReason(old, fresh) {
4692
- if (isBinary(old) || isBinary(fresh)) return "binary";
4693
- if (old.kind === "oversize" || fresh.kind === "oversize") return "too-large";
4694
- 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;
4695
4858
  }
4696
- function isBinary(side) {
4697
- 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
+ });
4698
4882
  }
4699
- // src/workspace/handlers.ts
4700
- async function createSession(ctx, req, res, lang) {
4701
- const body = await readJsonBody(req);
4702
- if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
4703
- const payload = asRecord(body.value);
4704
- const field = readWorkspaceField(payload, lang);
4705
- if (!field.ok) return sendError(res, 400, "bad-workspace", field.message);
4706
- const defer = payload["deferWorkspace"] === true;
4707
- if (defer && field.dir !== null) {
4708
- 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));
4709
4892
  }
4710
- if (defer && payload["create"] !== true) {
4711
- return sendError(
4712
- res,
4713
- 400,
4714
- "bad-request",
4715
- t("web.workspace_defer_needs_create", void 0, lang)
4716
- );
4893
+ if (stat3.kind !== "text") {
4894
+ return sendError(res, 415, "not-text", t("web.file_view_not_text", void 0, lang));
4717
4895
  }
4718
- if (field.dir === null && payload["create"] !== true) {
4719
- const sessionId = ctx.runtime.sessionId;
4720
- const bound = ctx.runtime.workspaces.of(sessionId);
4721
- return sendJson(res, 200, {
4722
- sessionId,
4723
- created: false,
4724
- workspace: bound ? toWireSessionWorkspace(bound) : null
4725
- });
4896
+ if (stat3.bytes > MAX_TEXT_BYTES) {
4897
+ return sendError(res, 413, "too-large", describeRefusal("too-large", lang));
4726
4898
  }
4727
- const role = typeof payload["role"] === "string" ? payload["role"].trim() : "";
4728
- const outcome = ctx.runtime.sessionFactory.create({
4729
- workspace: field.dir,
4730
- ...lang === void 0 ? {} : { lang },
4731
- reuse: payload["create"] !== true,
4732
- ...role === "" ? {} : { role }
4733
- });
4734
- if (!outcome.ok) {
4735
- if (outcome.reason === "unavailable") {
4736
- const mapped2 = unavailableToHttp(lang);
4737
- return sendError(res, mapped2.status, mapped2.code, mapped2.message);
4738
- }
4739
- if (outcome.reason === "unknown-role") {
4740
- const mapped2 = unknownRoleToHttp(outcome.detail, lang);
4741
- return sendError(res, mapped2.status, mapped2.code, mapped2.message);
4742
- }
4743
- const mapped = bindFailureToHttp(outcome, lang);
4744
- 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));
4745
4903
  }
4746
- ctx.hub.register(outcome.live.session);
4747
- if (defer) ctx.runtime.workspaces.defer(outcome.live.sessionId);
4748
- if (outcome.created) ctx.hub.notifySessionsChanged(outcome.live.sessionId);
4749
4904
  sendJson(res, 200, {
4750
- sessionId: outcome.live.sessionId,
4751
- created: outcome.created,
4752
- workspace: outcome.live.workspace ? toWireSessionWorkspace(outcome.live.workspace) : null
4905
+ path,
4906
+ text: out.text,
4907
+ bytes: out.bytes
4753
4908
  });
4754
4909
  }
4755
- function sessionWorkspace(ctx, res, sessionId, lang) {
4756
- if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
4757
- 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));
4758
4919
  }
4759
- const state = ctx.runtime.workspaces.stateOf(sessionId);
4760
- if (state !== "none") {
4761
- return sendJson(res, 200, {
4762
- 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)
4763
4932
  });
4764
4933
  }
4765
- if (ctx.hub.has(sessionId) || ctx.hub.isCooled(sessionId)) {
4766
- return sendJson(res, 200, {
4767
- binding: { state: "none" }
4768
- });
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);
4769
4944
  }
4770
- 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);
4771
4948
  }
4772
- async function bindWorkspace(ctx, req, res, sessionId, lang) {
4773
- if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
4774
- 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));
4775
4959
  }
4960
+ const root = resolveViewRoot(ctx, res, sessionId, lang);
4961
+ if (root === null) return;
4776
4962
  const body = await readJsonBody(req);
4777
4963
  if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
4778
- const parsed = readBindWorkspaceBody(asRecord(body.value), lang);
4779
- if (!parsed.ok) return sendError(res, 400, "bad-workspace", parsed.message);
4780
- const state = ctx.runtime.workspaces.stateOf(sessionId);
4781
- if (state !== "unbound") {
4782
- return sendError(
4783
- res,
4784
- 409,
4785
- "workspace-locked",
4786
- t("web.workspace_already_decided", void 0, lang)
4787
- );
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));
4788
4968
  }
4789
- const turn = ctx.hub.stateOf(sessionId);
4790
- if (turn !== "idle") {
4791
- return sendError(res, 409, "busy", t("web.workspace_turn_running", void 0, lang), {
4792
- "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
+ });
4793
4979
  });
4980
+ } catch {
4981
+ return refuse(res, "launch-failed", lang);
4794
4982
  }
4795
- if (parsed.kind === "none") {
4796
- ctx.runtime.workspaces.decideNone(sessionId);
4797
- return sendJson(res, 200, {
4798
- binding: { state: "none" }
4799
- });
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));
4800
4990
  }
4801
- const outcome = ctx.runtime.workspaces.bind(sessionId, parsed.root, lang);
4802
- if (!outcome.ok) {
4803
- const mapped = bindFailureToHttp(outcome, lang);
4804
- 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));
4805
4999
  }
5000
+ workspaces.forget(root);
4806
5001
  sendJson(res, 200, {
4807
- binding: { state: "bound", workspace: toWireSessionWorkspace(outcome.bound) }
5002
+ known: toWireTrustList(workspaces.known(), workspaces, current ?? null)
4808
5003
  });
4809
5004
  }
4810
- async function sessionDiff(ctx, res, sessionId, _lang) {
4811
- if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
4812
- return sendUnknownSession(res, sessionId);
4813
- }
4814
- const bound = ctx.runtime.workspaces.of(sessionId);
4815
- if (!bound) {
4816
- return sendJson(res, 200, {
4817
- source: "none",
4818
- root: "",
4819
- files: [],
4820
- truncated: false,
4821
- notes: ["no-workspace"]
4822
- });
4823
- }
4824
- sendJson(
4825
- res,
4826
- 200,
4827
- await collectWorkspaceDiff({
4828
- workDir: bound.workspace.root,
4829
- checkpoints: checkpointsOf(ctx, sessionId)
4830
- })
4831
- );
4832
- }
4833
5005
  function isKnownRoot(ctx, sessionId, root) {
4834
5006
  if (ctx.runtime.workspaces.of(sessionId)?.workspace.root === root) return true;
4835
5007
  return ctx.runtime.workspaces.known().some((w) => w.root === root);
@@ -4929,6 +5101,15 @@ async function handleApi(opts, req, res, method, segments, lang) {
4929
5101
  if (resource === "last-session" && first === void 0 && method === "PUT") {
4930
5102
  return putLastSession(ctx, req, res, lang);
4931
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
+ }
4932
5113
  if (resource === "sessions") {
4933
5114
  return handleSessions(opts, req, res, method, first, second, detail, lang);
4934
5115
  }
@@ -5270,6 +5451,7 @@ async function createWebServer(opts) {
5270
5451
  const unsubscribeTasks = observeBackgroundTasks((sessionId) => {
5271
5452
  hub.notifyTasksChanged(sessionId);
5272
5453
  });
5454
+ const unsubscribePlugins = opts.runtime.plugins?.onChange(() => hub.notifyPluginsChanged()) ?? (() => void 0);
5273
5455
  let handle = null;
5274
5456
  const server = createServer((req, res) => {
5275
5457
  if (handle) return handle(req, res);
@@ -5280,6 +5462,7 @@ async function createWebServer(opts) {
5280
5462
  const listened = await listen(server, binding.host, binding.port);
5281
5463
  if (!listened.ok) {
5282
5464
  unsubscribeTasks();
5465
+ unsubscribePlugins();
5283
5466
  return listened;
5284
5467
  }
5285
5468
  handle = buildHandler(opts, hub, binding, listened.port, webRoot);
@@ -5293,7 +5476,7 @@ async function createWebServer(opts) {
5293
5476
  ...webRoot === void 0 ? {} : { webRoot },
5294
5477
  observe: (sink, opts2) => hub.observe(sink, opts2 ?? {}),
5295
5478
  snapshot: () => hub.listSessions(),
5296
- close: makeCloser(server, hub, opts.runtime, unsubscribeTasks)
5479
+ close: makeCloser(server, hub, opts.runtime, unsubscribeTasks, unsubscribePlugins)
5297
5480
  }
5298
5481
  };
5299
5482
  }
@@ -5306,6 +5489,7 @@ function buildHandler(opts, hub, binding, port, webRoot) {
5306
5489
  return createRequestHandler({
5307
5490
  ctx: {
5308
5491
  hub,
5492
+ hostCompose: new HostComposeStore(),
5309
5493
  runtime: opts.runtime,
5310
5494
  version: opts.version,
5311
5495
  artifactsRoot: opts.artifactsRoot ?? opts.runtime.artifactsRoot,
@@ -5345,11 +5529,12 @@ function describeListenError(err, host, port) {
5345
5529
  }
5346
5530
  return t("web.listen_failed", { host, port, reason: err.message }, void 0);
5347
5531
  }
5348
- function makeCloser(server, hub, runtime, unsubscribeTasks) {
5532
+ function makeCloser(server, hub, runtime, unsubscribeTasks, unsubscribePlugins) {
5349
5533
  let closing = null;
5350
5534
  return () => {
5351
5535
  closing ??= (async () => {
5352
5536
  unsubscribeTasks();
5537
+ unsubscribePlugins();
5353
5538
  hub.shutdown();
5354
5539
  server.closeAllConnections();
5355
5540
  await new Promise((resolve4) => server.close(() => resolve4()));