@epoch-agent/server 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -254,8 +254,17 @@ sessionId 就是方案 30 §9.5 那条 bug 的形状 —— 编译得过、跑
254
254
 
255
255
  **录像那一条不再脱敏一次**:脱敏在落盘那一刻就做完了(core 的 `redactLine`,
256
256
  跑在已经序列化过的一整行上)。在这一层补一道会让下一个人以为盘上那一份是干净的
257
- —— 而盘上那一份才是真正会被 `cat` 的那份。帧数超过 `MAX_RECORDING_FRAMES`
258
- (4000)时截断 + `truncated: true`,界面必须把它画出来。
257
+ —— 而盘上那一份才是真正会被 `cat` 的那份。
258
+
259
+ 发出去之前**先把流式增量折回消息**(core 的 `coalesceRecording`):盘上是一个
260
+ `text-delta` 一帧,几千个输出 token 就是几千帧,而这些帧在运行结束之后没有独立
261
+ 含义(渲染那侧只是把它们拼成一段正文)。折完之后一帧 ≈ 一段正文 / 一次工具调用,
262
+ `MAX_RECORDING_FRAMES`(4000)这把尺子才量得到真东西 —— **它量的是折叠之后的帧数**。
263
+
264
+ 超了就截断 + `truncated: true`,界面必须把它画出来。⚠️ **截掉的是开头,留的是
265
+ 尾巴**(`slice(-N)`):一次运行里人要看的正是那句结论和 `finish` 那一帧上的账,
266
+ 而留头去尾会让正文断在半句话上 —— 屏幕上那半截自己不会说「我是半截的」
267
+ (判据同 protocol 的 `NoticeCode: 'response-truncated'`)。
259
268
 
260
269
  ⚠️ 这一组借的是 `WebRuntimeView.schedules`(`ScheduleControlView`,
261
270
  结构镜像在 [src/schedule/view.ts](./src/schedule/view.ts))。判定、注册进 OS、
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Lang, WireSkillImportFailure, WireSkillImportForm, WireSkillRemoveFailure, WireCapabilitiesResponse, WireSkillBodyResponse, EpochUserContent, AgentEvent, WireEnvelope, CustomCommandDef, ExpandedCommand, ModelRef, SetSelectionResult, WireCommandExpansion, WireCustomCommand, WireGoalPhase, WireGoalRefusal, WireTurnState, WireSessionFinish, ApprovalOutcome, QuestionAnswer, SessionSurfaceView, WireSessionReference, WireFileReference, ProviderType, ModelSelectionOrigin, WireModelCaveat, WireModelRejection, ContextBreakdown, PermissionLevel, WirePermissionRememberFailure, WirePluginSourceType, WirePluginRefuseReason, WirePluginEntry, WireModelSource, WireModelProbeStatus, ScheduleDefinition, ScheduleRun, ScheduleBackendKind, OperationType, ScheduleTrigger, WireScheduleIssue, ScheduleRunStatus, TrustLevel, WireSandboxReason, WireAuditOutcome, WireAuditCode, WireCachedApproval, WireCachedDecision, WireToolVerdict, WireToolGateAxis, WireSecurityResponse, WireRewindScope, WireRewindRequest, WireCheckpointSummary, WireRewindPreview, WireRewindResponse, ActiveAgentRole, WireSettingValue, WireSettingLayer, WireSettingWriteLayer, WireSettingFutile, WireSettingApply, WireSettingValueKind, WireSettingsResponse, EpochConfig, TokenUsage, WireCompressionLine, WireProviderSummary, WireToolSummary, Diagnostic, WireReplayMessage, WireFileChange, WireAuthGuard, WireWorkspaceDiffResponse, BackgroundTaskInfo, WireTasksResponse, WireBackgroundTask, WireScheduleRow } from '@epoch-agent/protocol';
1
+ import { Lang, WireSkillImportFailure, WireSkillImportForm, WireSkillRemoveFailure, WireCapabilitiesResponse, WireSkillBodyResponse, EpochUserContent, AgentEvent, WireEnvelope, CustomCommandDef, ExpandedCommand, ModelRef, SetSelectionResult, WireCommandExpansion, WireCustomCommand, WireGoalPhase, WireGoalRefusal, WireTurnState, WireSessionFinish, ApprovalOutcome, QuestionAnswer, SessionSurfaceView, WireSessionReference, WireFileReference, ProviderType, ModelSelectionOrigin, WireModelCaveat, WireModelRejection, ContextBreakdown, PermissionLevel, WirePermissionRememberFailure, WirePluginSourceType, WirePluginVisibility, WirePluginRefuseReason, WirePluginEntry, WireModelSource, WireModelProbeStatus, ScheduleDefinition, ScheduleRun, ScheduleBackendKind, OperationType, ScheduleTrigger, WireScheduleIssue, ScheduleRunStatus, TrustLevel, WireSandboxReason, WireAuditOutcome, WireAuditCode, WireCachedApproval, WireCachedDecision, WireToolVerdict, WireToolGateAxis, WireSecurityResponse, WireRewindScope, WireRewindRequest, WireCheckpointSummary, WireRewindPreview, WireRewindResponse, ActiveAgentRole, WireSettingValue, WireSettingLayer, WireSettingWriteLayer, WireSettingFutile, WireSettingApply, WireSettingValueKind, WireSettingsResponse, EpochConfig, TokenUsage, WireCompressionLine, WireProviderSummary, WireToolSummary, Diagnostic, WireReplayMessage, WireFileChange, WireAuthGuard, WireWorkspaceDiffResponse, BackgroundTaskInfo, WireTasksResponse, WireBackgroundTask, WireScheduleRow } from '@epoch-agent/protocol';
2
2
  import { SerializableApprovalRequest, SerializableQuestionRequest, ApprovalRevokeResult, LevelChangeResult, WorkspaceFilesView } from '@epoch-agent/runtime';
3
3
  import { ServerResponse } from 'node:http';
4
4
 
@@ -2263,11 +2263,12 @@ interface RuntimeModel {
2263
2263
  }
2264
2264
 
2265
2265
  /**
2266
- * 插件那一页的十条端点(方案 59 §六 E2,2026-08-21;本地源与市场管理 2026-09-04
2267
- * 补,判据在 protocol 的 `wire-plugin.ts` 文件头第一节那次**翻案**):
2266
+ * 插件那一页的十二条端点(方案 59 §六 E2,2026-08-21;本地源与市场管理 2026-09-04
2267
+ * 补,判据在 protocol 的 `wire-plugin.ts` 文件头第一节那次**翻案**;上传 2026-09-08
2268
+ * 补,判据在那份文件头第一节之二):
2268
2269
  *
2269
2270
  * ```
2270
- * GET /api/plugins 装着的 + 市场里有什么 + 已加的市场 + pendingRestart
2271
+ * GET /api/plugins 装着的 + 市场里有什么 + 已加的市场 + pendingRestart + uploadEnabled
2271
2272
  * POST /api/plugins/install/preview {ref} → 将安装什么,一个字节都不写
2272
2273
  * POST /api/plugins/install {ref, token} → 真装
2273
2274
  * POST /api/plugins/install/local/preview {path} → 同上,来源是本机一条目录
@@ -2277,6 +2278,8 @@ interface RuntimeModel {
2277
2278
  * POST /api/plugins/uninstall {name} → 卸载
2278
2279
  * POST /api/plugins/marketplaces {source} → 加一个市场
2279
2280
  * POST /api/plugins/marketplaces/remove {name} → 移除一个市场
2281
+ * POST /api/plugins/upload/preview {path} → 会打包哪些文件,一个字节都不发
2282
+ * POST /api/plugins/upload {path, token} → 打包 + 交给宿主
2280
2283
  * ```
2281
2284
  *
2282
2285
  * ## ⚠️ 这一层**一条策略判断都没有**
@@ -2290,10 +2293,10 @@ interface RuntimeModel {
2290
2293
  * 界面上是一条画成可点、点了却被拒的行(判据同 `PluginSearchHit.installable`
2291
2294
  * 为什么不从结果里剔掉远程那条)。
2292
2295
  *
2293
- * ## ⚠️ 那一道闸:**九条 POST 在回环之外一律 403**,这是这个文件的正题
2296
+ * ## ⚠️ 那一道闸:**十一条 POST 在回环之外一律 403**,这是这个文件的正题
2294
2297
  *
2295
2298
  * 判据全文在 protocol 的 [wire-plugin.ts](../../protocol/src/wire-plugin.ts) 文件头
2296
- * 第二节,这里记它对这一层的五条直接后果:
2299
+ * 第二节,这里记它对这一层的**六**条直接后果:
2297
2300
  *
2298
2301
  * 1. **`ctx.lanExposed` 为真时 403 `plugin-write-lan-exposed`,一个字节都不写。**
2299
2302
  * 装一包插件会落下 `hooks.json` 和 `mcp.json`,那两样**起子进程** ——
@@ -2312,6 +2315,10 @@ interface RuntimeModel {
2312
2315
  * 唯一后果是那一页在 LAN 那一档下一片空白,
2313
2316
  * 偏偏那时用户最想知道的就是「我装了什么、生效了没有」。
2314
2317
  * (对比 `GET /api/mcp/config` 为什么连读都挡:那份文件里的 `env` 是明文密钥。)
2318
+ * 6. **上传那两条是这张表里最该挡的**(2026-09-08):它们读这台机器上任意一条
2319
+ * 目录、把内容**发到外面去**。放行的话,一个 LAN 上的人能指着 `~/.ssh` 说
2320
+ * 「上传它」。⚠️ **预览那一条同样挡**,虽然它一个字节都不发 ——
2321
+ * 它回的那份**文件清单本身就是情报**(拿它能探这台机器上有什么)。
2315
2322
  *
2316
2323
  * ## ⚠️ 这一片可以整个不存在 —— 503 `no-plugin-control`
2317
2324
  *
@@ -2320,6 +2327,11 @@ interface RuntimeModel {
2320
2327
  * JSDoc 上)。六条一律 503,判据同 `no-goal-store`:那是一句关于**这个部署**的
2321
2328
  * 真话,而一份「装了 0 个插件」的空列表会让用户去找一个不存在的安装按钮。
2322
2329
  *
2330
+ * ⚠️ **上传那两条还有第二档「没开」,而它比 503 那一档窄**:`runtime.plugins`
2331
+ * 在、但宿主没给 `upload` 回调 —— 那时它们回 **200 + `upload-unavailable`**,
2332
+ * 不是 503。两档分得开是有用的:503 说「这一整页没开」,那一档说
2333
+ * 「页开着,只是没有交出去这条路」,而后者正是 `epoch web --plugins` 的常态。
2334
+ *
2323
2335
  * ⚠️ **默认的 `epoch web` 就落在这一档上**(不给 `--plugins` 就不传
2324
2336
  * `hostMarketplaces`)。那一格 2026-08-21 答掉了:`epoch web --plugins` 才接上
2325
2337
  * (传 `{ sources: [] }`),**默认关**,判据在
@@ -2378,6 +2390,26 @@ interface PluginHitView {
2378
2390
  source: string;
2379
2391
  category?: string;
2380
2392
  keywords?: readonly string[];
2393
+ /** 目录里那一格「谁写的」。**摊平在投影里做**,判据在 `WirePluginHit.author` 上 */
2394
+ author?: {
2395
+ name: string;
2396
+ };
2397
+ /**
2398
+ * 市场那一侧数的两个计数(2026-09-08)。**可选**,而缺席在网线上变 `null`
2399
+ * 不是 `0` —— 判据在 `WirePluginHit.downloads` 上(「没人报」和「一次都没装过」
2400
+ * 是两句不同的话)。
2401
+ */
2402
+ downloads?: number;
2403
+ likes?: number;
2404
+ /** 展示用的那几格(2026-09-08)。全部可选 → 网线上一律 `null` */
2405
+ displayName?: string;
2406
+ icon?: string;
2407
+ /** **市场说的**版本。`updateAvailable` 是拿它和装着的那个比出来的 */
2408
+ version?: string;
2409
+ changelog?: string;
2410
+ size?: number;
2411
+ homepage?: string;
2412
+ visibility?: WirePluginVisibility;
2381
2413
  };
2382
2414
  sourceType?: WirePluginSourceType;
2383
2415
  installable: boolean;
@@ -2460,7 +2492,49 @@ type MarketOutcomeView = {
2460
2492
  marketplace?: MarketplaceView;
2461
2493
  } | RefusalView;
2462
2494
  /**
2463
- * `PluginControl`(runtime)在服务端眼里的样子 —— **只有那十一个动词加一格布尔**。
2495
+ * `PackPlan`(core,经 runtime 原样透出)的镜像 —— 「会打包什么」那一屏的内容。
2496
+ *
2497
+ * ⚠️ 借的是**整份 `files`** 而不是一个长度:判据在 `WirePluginUploadPlan.files`
2498
+ * 上(这条路上最常见的事故是「目录里有一份 `.env`」,一个计数答不出那件事)。
2499
+ */
2500
+ interface PackPlanView {
2501
+ manifest: {
2502
+ name: string;
2503
+ version: string;
2504
+ description?: string;
2505
+ author?: {
2506
+ name: string;
2507
+ };
2508
+ };
2509
+ files: readonly {
2510
+ path: string;
2511
+ bytes: number;
2512
+ }[];
2513
+ totalBytes: number;
2514
+ suspicious: readonly string[];
2515
+ skippedLinks: readonly string[];
2516
+ }
2517
+ type UploadPreviewOutcomeView = {
2518
+ ok: true;
2519
+ plan: PackPlanView;
2520
+ token: string;
2521
+ } | RefusalView;
2522
+ /**
2523
+ * 上传的结局。
2524
+ *
2525
+ * ⚠️ 成功那一档借的是 `detail`(**宿主原话**)而不是我们编的一句 —— 判据在
2526
+ * `WirePluginUploadResponse.detail` 上:上传之后发生什么(直接上架 / 排队审核)
2527
+ * 是宿主那一侧的流程,这一层编不出来。
2528
+ */
2529
+ type UploadOutcomeView = {
2530
+ ok: true;
2531
+ detail: string;
2532
+ name: string;
2533
+ version: string;
2534
+ bytes: number;
2535
+ } | RefusalView;
2536
+ /**
2537
+ * `PluginControl`(runtime)在服务端眼里的样子 —— **只有那十四个动词加两格布尔**。
2464
2538
  *
2465
2539
  * 镜像里只有它们,于是这个包**写不出** core 的 `setPluginEnabled`
2466
2540
  * (停用一个插件会静默拿掉别人正依赖的一批命令,正当性和「装 / 卸」不是一回事);
@@ -2486,7 +2560,29 @@ interface PluginControlView {
2486
2560
  marketplaces(): readonly MarketplaceView[];
2487
2561
  addMarketplace(source: string, lang?: Lang): Promise<MarketOutcomeView>;
2488
2562
  removeMarketplace(name: string, lang?: Lang): Promise<MarketOutcomeView>;
2563
+ /**
2564
+ * 「会打包什么」。⚠️ **同步的**(runtime 那一层只走一遍目录,不起子进程)——
2565
+ * 镜像上写成 `Promise` 也满足,但那会让这一层顺手 `await` 一个不用等的东西
2566
+ */
2567
+ previewUpload(path: string, lang?: Lang): UploadPreviewOutcomeView;
2568
+ upload(path: string, token: string,
2569
+ /** 用户在那一屏上填的。**宿主没声明要问的格子由下一层丢掉**,这一层不过滤 */
2570
+ form: {
2571
+ visibility?: WirePluginVisibility;
2572
+ changelog?: string;
2573
+ }, lang?: Lang): Promise<UploadOutcomeView>;
2489
2574
  readonly pendingRestart: boolean;
2575
+ /** 上传表单多问哪几格。**两个恒有的布尔**,runtime 那一层算好的 */
2576
+ readonly uploadOptions: {
2577
+ visibility: boolean;
2578
+ changelog: boolean;
2579
+ };
2580
+ /**
2581
+ * 宿主给了上传出口吗。**runtime 算好的结论**,这一层原样投影上网线 ——
2582
+ * 在这儿重判一次(比如看那个回调在不在)就是给同一个问题第二个答案,
2583
+ * 判据同文件头那句「这一层一条策略判断都没有」。
2584
+ */
2585
+ readonly uploadEnabled: boolean;
2490
2586
  }
2491
2587
  /**
2492
2588
  * 装着的一条 → 网线上那一行。
@@ -2666,8 +2762,26 @@ interface ScheduleCreateView {
2666
2762
  endDate?: string;
2667
2763
  enabled?: boolean;
2668
2764
  }
2669
- /** 改一条任务 —— core 的 `UpdateScheduleInput`。没给的字段一个都不动 */
2670
- type ScheduleUpdateView = Partial<ScheduleCreateView>;
2765
+ /**
2766
+ * 改一条任务 —— core 的 `UpdateScheduleInput`。没给的字段一个都不动。
2767
+ *
2768
+ * ⚠️ **四格收 `null` = 清空这一格**,和网线上那份(`WireScheduleUpdateRequest`)
2769
+ * 逐字段对得上。别写成 `Partial<ScheduleCreateView>`(2026-09-07 之前就是那样):
2770
+ * 那一版比真身**窄**,而窄的方向 TS 不报 —— 入参是逆变的,一个「什么都收得下」
2771
+ * 的真身赋给一个「只收 string」的镜像永远合法。于是这一层压根表达不出「清空」,
2772
+ * `schedule/body.ts` 只好把 `null` 翻成空串往下递,而 core 从来不认空串是清空。
2773
+ * 那次的表现是:表单上两格生效区间都没填,保存却报两行「生效区间不对: 」。
2774
+ *
2775
+ * 也就是说**这一格的正确性没有编译器守着**,只有这段注释和用例。判据全文在
2776
+ * core 的 `UpdateScheduleInput` 上。
2777
+ */
2778
+ type ScheduleUpdateView = Partial<Omit<ScheduleCreateView, 'workDir' | 'model' | 'startDate' | 'endDate'>> & {
2779
+ /** `null` = 不绑工作区了 → 落回 `~/.epoch/automation/<id>/`(runtime 那一层解析) */
2780
+ workDir?: string | null;
2781
+ model?: string | null;
2782
+ startDate?: string | null;
2783
+ endDate?: string | null;
2784
+ };
2671
2785
  /** 存下来了没有 —— core / runtime 的 `ScheduleSaveResult` */
2672
2786
  interface ScheduleSaveView {
2673
2787
  /** 校验不过时 `undefined`,且库里一个字都没写 */
@@ -4884,12 +4998,17 @@ declare function collectTasks(sessionId: string, registry: TaskRegistryView): Wi
4884
4998
  */
4885
4999
 
4886
5000
  /**
4887
- * 一份录像最多回多少帧。
5001
+ * 一份录像最多回多少帧 —— **折叠之后的帧**。
5002
+ *
5003
+ * 一次跑飞的运行能写出几万帧,整份推给浏览器会让那个标签页卡死。超了就截断 +
5004
+ * `truncated: true` —— **必须说出来**,判据同 `WireSkillBodyResponse.truncated`:
5005
+ * 一份看起来完整、其实少了后半截的回放,比一句「太长了没给全」坏得多。
4888
5006
  *
4889
- * 一次跑飞的运行能写出几万帧(每个 `text-delta` 一帧),整份推给浏览器会让
4890
- * 那个标签页卡死。超了就截断 + `truncated: true` —— **必须说出来**,
4891
- * 判据同 `WireSkillBodyResponse.truncated`:一份看起来完整、其实少了后半截的
4892
- * 回放,比一句「太长了没给全」坏得多。
5007
+ * ⚠️ **这把尺子量的是折叠之后的帧数**(2026-09-07 改准)。原来量的是盘上的原始
5008
+ * 帧数,而盘上「每个 `text-delta` 一帧」—— 于是几千个输出 token 就是几千帧,
5009
+ * **一次正常的多轮任务**(实测:27 轮、0.016 美元)就撞上了这道闸门。
5010
+ * 折叠在 core 的 `coalesceRecording` 里,折完一帧 ≈ 一段正文 / 一次工具调用,
5011
+ * 4000 才重新是「一次真正巨大的运行」的意思。
4893
5012
  */
4894
5013
  declare const MAX_RECORDING_FRAMES = 4000;
4895
5014
  /**
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createServer } from 'http';
2
- import { t, ApprovalRelay, QuestionRelay, serializeStream, withRoleScope, withModelScope, withToolScope, nextRunAt, skillIndexDescription, SANDBOX_EXCLUDES, SANDBOX_COVERS, isSystemOpenable, backgroundTaskOutput, listBackgroundTasks, providerLabel, MAX_REASON_CHARS, MAX_OBJECTIVE_CHARS, MAX_GOAL_MAX_ROUNDS, DEFAULT_GOAL_MAX_ROUNDS, SCHEDULE_DEFAULT_ALLOWLIST, SCHEDULE_DEFAULTS, readRecording } from '@epoch-agent/runtime';
2
+ import { t, ApprovalRelay, QuestionRelay, serializeStream, withRoleScope, withModelScope, withToolScope, nextRunAt, skillIndexDescription, SANDBOX_EXCLUDES, SANDBOX_COVERS, isSystemOpenable, backgroundTaskOutput, listBackgroundTasks, providerLabel, MAX_REASON_CHARS, MAX_OBJECTIVE_CHARS, MAX_GOAL_MAX_ROUNDS, DEFAULT_GOAL_MAX_ROUNDS, SCHEDULE_DEFAULT_ALLOWLIST, SCHEDULE_DEFAULTS, coalesceRecording, readRecording } from '@epoch-agent/runtime';
3
3
  import { timingSafeEqual, randomBytes } from 'crypto';
4
4
  import { PLAN_OUTCOMES, WIRE_AUTH_COOKIE, WIRE_AUTH_COOKIE_ATTRS, WIRE_AUTH_TOKEN_PARAM, extractAllMentions, MAX_ATTACH_FILES, utf8ByteLength, MAX_SESSION_REFS, attachSessionSurface, isWireRewindScope, WIRE_REWIND_SCOPES, MAX_ATTACH_TOTAL_BYTES, MAX_ATTACH_BYTES, WIRE_LANG_PARAM, isLang, isQuestionAnswerMap, isProviderType, apiKeyEnvVar, WIRE_FILE_STAT_MAX, WIRE_SETTING_WRITE_LAYERS, isWirePlanAction, WIRE_PLAN_ACTIONS, isPermissionLevel, PERMISSION_LEVELS, collectPendingApprovals, unfixedRules, isWireSettingWriteLayer, parseModelRef, WIRE_GOAL_ACTIONS, OPERATION_TYPES, isOperationType } from '@epoch-agent/protocol';
5
5
  import { resolve, join, relative, isAbsolute, sep, dirname, basename, extname } from 'path';
@@ -2448,7 +2448,8 @@ function toWirePluginEntry(entry) {
2448
2448
  counts: entry.counts ? toWireCounts(entry.counts) : null
2449
2449
  };
2450
2450
  }
2451
- function toWirePluginHit(hit, installedNames) {
2451
+ function toWirePluginHit(hit, installed) {
2452
+ const mine = installed.get(hit.entry.name);
2452
2453
  return {
2453
2454
  marketplace: hit.marketplace,
2454
2455
  ref: hit.ref,
@@ -2458,7 +2459,18 @@ function toWirePluginHit(hit, installedNames) {
2458
2459
  keywords: [...hit.entry.keywords ?? []],
2459
2460
  sourceType: hit.sourceType ?? null,
2460
2461
  installable: hit.installable,
2461
- installed: installedNames.has(hit.entry.name)
2462
+ installed: mine !== void 0,
2463
+ author: hit.entry.author?.name ?? null,
2464
+ downloads: hit.entry.downloads ?? null,
2465
+ likes: hit.entry.likes ?? null,
2466
+ displayName: hit.entry.displayName ?? null,
2467
+ icon: hit.entry.icon ?? null,
2468
+ version: hit.entry.version ?? null,
2469
+ changelog: hit.entry.changelog ?? null,
2470
+ size: hit.entry.size ?? null,
2471
+ homepage: hit.entry.homepage ?? null,
2472
+ visibility: hit.entry.visibility ?? null,
2473
+ updateAvailable: mine !== void 0 && !mine.linked && hit.entry.version !== void 0 && hit.entry.version !== mine.version
2462
2474
  };
2463
2475
  }
2464
2476
  function toWireMarketplace(market) {
@@ -2518,12 +2530,16 @@ function listPlugins(ctx, res, lang) {
2518
2530
  const control = controlOf(ctx, res, lang);
2519
2531
  if (!control) return;
2520
2532
  const installed = control.list();
2521
- const installedNames = new Set(installed.map((p) => p.name));
2533
+ const installedMap = new Map(
2534
+ installed.map((p) => [p.name, { version: p.version, linked: p.linked }])
2535
+ );
2522
2536
  sendJson(res, 200, {
2523
2537
  installed: installed.map(toWirePluginEntry),
2524
- hits: control.search().map((hit) => toWirePluginHit(hit, installedNames)),
2538
+ hits: control.search().map((hit) => toWirePluginHit(hit, installedMap)),
2525
2539
  marketplaces: control.marketplaces().map(toWireMarketplace),
2526
- pendingRestart: control.pendingRestart
2540
+ pendingRestart: control.pendingRestart,
2541
+ uploadEnabled: control.uploadEnabled,
2542
+ uploadOptions: control.uploadOptions
2527
2543
  });
2528
2544
  }
2529
2545
  function sendPreview(res, outcome) {
@@ -2658,6 +2674,71 @@ async function removePluginMarketplace(ctx, req, res, lang) {
2658
2674
  if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2659
2675
  sendMarket(res, await control.removeMarketplace(fieldOf(asRecord(body.value), "name"), lang));
2660
2676
  }
2677
+ function previewPluginUpload(ctx, req, res, lang) {
2678
+ return withUploadBody(ctx, req, res, lang, (control, payload) => {
2679
+ sendUploadPreview(res, control.previewUpload(fieldOf(payload, "path"), lang));
2680
+ });
2681
+ }
2682
+ function uploadPlugin(ctx, req, res, lang) {
2683
+ return withUploadBody(ctx, req, res, lang, async (control, payload) => {
2684
+ sendUpload(
2685
+ res,
2686
+ await control.upload(
2687
+ fieldOf(payload, "path"),
2688
+ fieldOf(payload, "token"),
2689
+ {
2690
+ ...fieldOf(payload, "visibility") === "private" ? { visibility: "private" } : fieldOf(payload, "visibility") === "public" ? { visibility: "public" } : {},
2691
+ ...fieldOf(payload, "changelog") ? { changelog: fieldOf(payload, "changelog") } : {}
2692
+ },
2693
+ lang
2694
+ )
2695
+ );
2696
+ });
2697
+ }
2698
+ async function withUploadBody(ctx, req, res, lang, run) {
2699
+ if (refusedByLan(ctx, res, lang)) return;
2700
+ const control = controlOf(ctx, res, lang);
2701
+ if (!control) return;
2702
+ const body = await readJsonBody(req);
2703
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2704
+ await run(control, asRecord(body.value));
2705
+ }
2706
+ function sendUploadPreview(res, outcome) {
2707
+ if (!outcome.ok) {
2708
+ return sendJson(res, 200, {
2709
+ ok: false,
2710
+ plan: null,
2711
+ token: null,
2712
+ reason: outcome.reason,
2713
+ detail: outcome.detail
2714
+ });
2715
+ }
2716
+ const { manifest, files, totalBytes, suspicious, skippedLinks } = outcome.plan;
2717
+ sendJson(res, 200, {
2718
+ ok: true,
2719
+ plan: {
2720
+ name: manifest.name,
2721
+ version: manifest.version,
2722
+ description: manifest.description ?? "",
2723
+ author: manifest.author?.name ?? null,
2724
+ files: files.map((f) => ({ path: f.path, bytes: f.bytes })),
2725
+ totalBytes,
2726
+ suspicious: [...suspicious],
2727
+ skippedLinks: [...skippedLinks]
2728
+ },
2729
+ token: outcome.token,
2730
+ reason: null,
2731
+ detail: null
2732
+ });
2733
+ }
2734
+ function sendUpload(res, outcome) {
2735
+ sendJson(res, 200, {
2736
+ ok: outcome.ok,
2737
+ detail: outcome.detail,
2738
+ uploaded: outcome.ok ? { name: outcome.name, version: outcome.version, bytes: outcome.bytes } : null,
2739
+ reason: outcome.ok ? null : outcome.reason
2740
+ });
2741
+ }
2661
2742
  function toWireProvider(option, lang) {
2662
2743
  return {
2663
2744
  type: option.type,
@@ -2855,21 +2936,28 @@ function numberList(value) {
2855
2936
  }
2856
2937
  return out;
2857
2938
  }
2858
- function optionals(raw, lang) {
2939
+ var CLEARABLE = ["model", "workDir", "startDate", "endDate"];
2940
+ function optionals(raw, allowClear, lang) {
2859
2941
  const out = {};
2860
- for (const key of ["name", "prompt", "model", "workDir", "startDate", "endDate"]) {
2942
+ const bad = (field) => ({
2943
+ ok: false,
2944
+ message: t("web.schedule_bad_field", { field }, lang)
2945
+ });
2946
+ for (const key of ["name", "prompt"]) {
2947
+ const value = raw[key];
2948
+ if (value === void 0) continue;
2949
+ if (typeof value !== "string") return bad(key);
2950
+ out[key] = value;
2951
+ }
2952
+ for (const key of CLEARABLE) {
2861
2953
  const value = raw[key];
2862
2954
  if (value === void 0) continue;
2863
2955
  if (value === null) {
2864
- if (key === "name" || key === "prompt") {
2865
- return { ok: false, message: t("web.schedule_bad_field", { field: key }, lang) };
2866
- }
2867
- out[key] = "";
2956
+ if (!allowClear) return bad(key);
2957
+ out[key] = null;
2868
2958
  continue;
2869
2959
  }
2870
- if (typeof value !== "string") {
2871
- return { ok: false, message: t("web.schedule_bad_field", { field: key }, lang) };
2872
- }
2960
+ if (typeof value !== "string") return bad(key);
2873
2961
  out[key] = value;
2874
2962
  }
2875
2963
  for (const key of ["maxTurns", "maxBudgetUsd", "timeoutMs"]) {
@@ -2939,7 +3027,7 @@ function parseCreate2(body, lang) {
2939
3027
  return { ok: false, message: t("web.schedule_bad_trigger", void 0, lang) };
2940
3028
  if (budget === null)
2941
3029
  return { ok: false, message: t("web.schedule_budget_required", void 0, lang) };
2942
- const rest = optionals(raw, lang);
3030
+ const rest = optionals(raw, false, lang);
2943
3031
  if (!rest.ok) return rest;
2944
3032
  return {
2945
3033
  ok: true,
@@ -2947,7 +3035,7 @@ function parseCreate2(body, lang) {
2947
3035
  };
2948
3036
  }
2949
3037
  function parseUpdate2(body, lang) {
2950
- return optionals(asRecord(body), lang);
3038
+ return optionals(asRecord(body), true, lang);
2951
3039
  }
2952
3040
  function parseFix(body, lang) {
2953
3041
  const runId = asRecord(body)["runId"];
@@ -3105,14 +3193,14 @@ function toRecording(run) {
3105
3193
  if (run.logPath === void 0) {
3106
3194
  return { runId: run.id, frames: [], missing: true, truncated: false };
3107
3195
  }
3108
- const frames = readRecording(run.logPath);
3196
+ const frames = coalesceRecording(readRecording(run.logPath));
3109
3197
  if (frames.length === 0) {
3110
3198
  return { runId: run.id, frames: [], missing: true, truncated: false };
3111
3199
  }
3112
3200
  const truncated = frames.length > MAX_RECORDING_FRAMES;
3113
3201
  return {
3114
3202
  runId: run.id,
3115
- frames: truncated ? frames.slice(0, MAX_RECORDING_FRAMES) : frames,
3203
+ frames: truncated ? frames.slice(-MAX_RECORDING_FRAMES) : frames,
3116
3204
  missing: false,
3117
3205
  truncated
3118
3206
  };
@@ -4363,6 +4451,12 @@ async function handleApi(opts, req, res, method, segments, lang) {
4363
4451
  if (resource === "plugins" && first === "marketplaces" && second === void 0 && method === "POST") {
4364
4452
  return addPluginMarketplace(ctx, req, res, lang);
4365
4453
  }
4454
+ if (resource === "plugins" && first === "upload" && second === "preview" && method === "POST") {
4455
+ return previewPluginUpload(ctx, req, res, lang);
4456
+ }
4457
+ if (resource === "plugins" && first === "upload" && second === void 0 && method === "POST") {
4458
+ return uploadPlugin(ctx, req, res, lang);
4459
+ }
4366
4460
  if (resource === "providers" && first === void 0 && isRead(method)) {
4367
4461
  return listProviders(ctx, res, lang);
4368
4462
  }