@epoch-agent/server 0.13.0 → 0.15.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.d.ts CHANGED
@@ -2709,39 +2709,31 @@ interface SessionLoopView {
2709
2709
  *
2710
2710
  * 另一项(`usedTokens`)反过来**只有浏览器有**,见 {@link readModelSetBody}。
2711
2711
  *
2712
- * ## ✅ 2026-08-16 合并时收掉了(原文是一张 📮,留着当判据)
2712
+ * ## ⚠️ 斜杠命令的 `model:` 必须按会话取,**别让它再借进程那一份**
2713
2713
  *
2714
- * 自定义斜杠命令 frontmatter 的 `model:`(「这一条命令只换这一轮」)走的是
2715
- * [commands.ts](./commands.ts) 的 `scopeTurn()`,它读的**曾经**是 `runtime.model`
2716
- * 那一格 —— 也就是引导会话那一份。模型选择这一轮变成会话级之后,那条路在
2717
- * 第二个会话上的表现是:
2714
+ * 自定义斜杠命令 frontmatter 的 `model:`(「这一条命令只换这一轮」)走
2715
+ * [commands.ts](./commands.ts) 的 `scopeTurn()`。那一格的真源是
2716
+ * `ctx.runtime.sessionFactory.get(sessionId)?.model`,由调用方递进来
2717
+ * (形状照 `checkpointsOf(ctx, sessionId)`)—— **`commands.ts` 上没有、也别再加回
2718
+ * 一个 `model: ModelTurnsView | null`**:那种借法借到的恒是引导会话那一份。
2718
2719
  *
2719
- * - 那条命令的 `model:` **对这个会话一点作用都没有**(B 的循环读的是 B 自己
2720
+ * 借错了的症状有两半,第二半更糟:
2721
+ *
2722
+ * - 那条命令的 `model:` **对当前这个会话一点作用都没有**(B 的循环读的是 B 自己
2720
2723
  * 那个 router,而 `enterTurn` 换的是引导会话那个);
2721
- * - 更糟的一半:它**顺带把引导会话的选择换掉了一轮**。A 正好在跑的话,
2722
- * A 那一轮会用上 B 那条命令点名的模型。
2723
- *
2724
- * ⚠️ **这不是这一轮引入的一个新 bug,是一个旧 bug 换了形状**:在会话级之前,
2725
- * B 那条命令换的是**所有人共用**的那个选择,A 同样会被拖下水。两版都错,
2726
- * 而这一版多了「对 B 无效」这一半。如实记着,别当成没动过。
2727
- *
2728
- * **写这张 📮 的那一轮不改**:`commands.ts` / `api.ts` 不归它,而正确的改法要动
2729
- * 它们两个([方案 30 §十二](../../../docs/verify/VERIFY_RECORD-30-web-multi-session.md)
2730
- * 的第 3 条:不绕过去、不伸手改、就地写下来)。合并的那一轮照着三步收完了:
2731
- *
2732
- * 1. `RuntimeCommandModel`(`commands.ts` 上那个 `model: ModelTurnsView | null`)
2733
- * **整个删掉**了 —— 它借的恒是进程那一份。`expandUserContent()` /
2734
- * `scopeTurn()` 改成从调用方收这一格,形状照 `checkpointsOf(ctx, sessionId)`;
2735
- * 2. 真源本来就在手边:`ctx.runtime.sessionFactory.get(sessionId)?.model`。
2736
- * **runtime 一个字没加** —— `ModelControl` 上五个动词一直都全,
2737
- * 只差 server 这一跳;
2738
- * 3. `enterTurn` 加进了 `sessions/scope.ts` 那份镜像。原文说这一步「有意留给
2739
- * 收的人做」,因为加它等于承认「这条路上有一个和 `run()` 成对的还原动作」——
2740
- * 那句话由 `commands.ts` 说了,判据抄在 `SessionModelView.enterTurn` 上。
2741
- *
2742
- * ⚠️ 一处**没有**跟着变的:取不到会话(冷却中、或 `sessionFactory` 里没有)时
2724
+ * - 它**顺带把引导会话的选择换掉了一轮** —— A 正好在跑的话,A 那一轮会用上
2725
+ * B 那条命令点名的模型。
2726
+ *
2727
+ * ⚠️ 这在会话级之前是**同一个 bug 的另一个形状**:那时 B 那条命令换的是所有人
2728
+ * 共用的那个选择,A 同样被拖下水。两版都错,如实记着,别当成没动过。
2729
+ *
2730
+ * ⚠️ 一处**刻意不跟着变**:取不到会话(冷却中、或 `sessionFactory` 里没有)时
2743
2731
  * 传 null,`withModelScope` 对 null 是零开销直通 —— 命令正文照跑,只有 `model:`
2744
2732
  * 那一半不生效。**和 provider 没配起来时同一档**,判据在 `api.ts` 那一处。
2733
+ *
2734
+ * ⚠️ `enterTurn` 在 `sessions/scope.ts` 那份镜像里是有意加的:加它等于承认
2735
+ * 「这条路上有一个和 `run()` 成对的还原动作」,那句话由 `commands.ts` 说,
2736
+ * 判据抄在 `SessionModelView.enterTurn` 上。
2745
2737
  */
2746
2738
 
2747
2739
  /**
@@ -3052,6 +3044,19 @@ interface MarketplaceView {
3052
3044
  entries: number;
3053
3045
  addedAt: number;
3054
3046
  local: boolean;
3047
+ /** 这个市场声明的分类层级(2026-09-21)。缺席 = 它没声明,判据在 runtime 那一格上 */
3048
+ categories?: readonly CategoryView[];
3049
+ }
3050
+ /**
3051
+ * `MarketplaceCategory`(core,经 runtime 原样透出)的镜像。
3052
+ *
3053
+ * ⚠️ **两格都可选**,和网线上那一份刻意不同形:补齐是这一层的活
3054
+ * ({@link toWireCategory}),而镜像要如实描述**收到的**是什么。
3055
+ */
3056
+ interface CategoryView {
3057
+ id: string;
3058
+ label?: string;
3059
+ children?: readonly CategoryView[];
3055
3060
  }
3056
3061
  /** 加 / 移除一个市场的结局。移除成功那一档没有 `marketplace` —— 它已经不在表里了 */
3057
3062
  type MarketOutcomeView = {
@@ -3385,6 +3390,8 @@ interface ScheduleCreateView {
3385
3390
  /** 不给就落到 `~/.epoch/automation/<id>/`,**不是**服务进程的 cwd */
3386
3391
  workDir?: string;
3387
3392
  model?: string;
3393
+ /** 「召唤专家」——这条任务用哪个身份跑。见 `WireScheduleCreateRequest.role` */
3394
+ role?: string;
3388
3395
  permission: PermissionLevel;
3389
3396
  allowTools?: readonly string[];
3390
3397
  allowOperations?: readonly OperationType[];
@@ -3397,11 +3404,16 @@ interface ScheduleCreateView {
3397
3404
  startDate?: string;
3398
3405
  endDate?: string;
3399
3406
  enabled?: boolean;
3407
+ /**
3408
+ * 跑完的会话直接进会话列表吗。不给 = 收进归档,判据全文在
3409
+ * `ScheduleDefinition.showInSessionList` 上。
3410
+ */
3411
+ showInSessionList?: boolean;
3400
3412
  }
3401
3413
  /**
3402
3414
  * 改一条任务 —— core 的 `UpdateScheduleInput`。没给的字段一个都不动。
3403
3415
  *
3404
- * ⚠️ **四格收 `null` = 清空这一格**,和网线上那份(`WireScheduleUpdateRequest`)
3416
+ * ⚠️ **五格收 `null` = 清空这一格**,和网线上那份(`WireScheduleUpdateRequest`)
3405
3417
  * 逐字段对得上。别写成 `Partial<ScheduleCreateView>`(2026-09-07 之前就是那样):
3406
3418
  * 那一版比真身**窄**,而窄的方向 TS 不报 —— 入参是逆变的,一个「什么都收得下」
3407
3419
  * 的真身赋给一个「只收 string」的镜像永远合法。于是这一层压根表达不出「清空」,
@@ -3411,10 +3423,12 @@ interface ScheduleCreateView {
3411
3423
  * 也就是说**这一格的正确性没有编译器守着**,只有这段注释和用例。判据全文在
3412
3424
  * core 的 `UpdateScheduleInput` 上。
3413
3425
  */
3414
- type ScheduleUpdateView = Partial<Omit<ScheduleCreateView, 'workDir' | 'model' | 'startDate' | 'endDate'>> & {
3426
+ type ScheduleUpdateView = Partial<Omit<ScheduleCreateView, 'workDir' | 'model' | 'role' | 'startDate' | 'endDate'>> & {
3415
3427
  /** `null` = 不绑工作区了 → 落回 `~/.epoch/automation/<id>/`(runtime 那一层解析) */
3416
3428
  workDir?: string | null;
3417
3429
  model?: string | null;
3430
+ /** `null` = 回到默认 agent */
3431
+ role?: string | null;
3418
3432
  startDate?: string | null;
3419
3433
  endDate?: string | null;
3420
3434
  };
@@ -3736,68 +3750,42 @@ interface WorkspaceView {
3736
3750
  * 是 `<root>/.epoch/policies`),**那时这里要改**:`runtime.policy` 得按
3737
3751
  * `workspaces.of(sessionId)` 重算,而不是读装配时那一份。
3738
3752
  *
3739
- * ## ✅ 收掉了(2026-08-17):`permission.level` 这一格曾经答的是**引导会话**
3753
+ * ## ⚠️ `permission.level` 必须按会话取 —— **别动 `session` 那个参数**
3754
+ *
3755
+ * 这一格今天对了,靠的是 [api.ts](./api.ts) 的 `getSecurity()` 递进来的那一个实参
3756
+ * (第 4 个):`ctx.runtime.sessionFactory.get(sessionId)?.permissions ?? null`。
3740
3757
  *
3741
- * 下面整节是那张 📮 的原文,**一个字没改**。它留着不是纪念品:这一格今天对了
3742
- * 靠的是 `api.ts` 里那**一个实参**,而那一行看上去像是可有可无的第四个参数。
3743
- * 哪天有人为了「少传一个东西」把它删回三参,症状就是下面写的那些 ——
3744
- * 印错一个词、外加一张在最该发黄的时候是绿的卡。**判据比修法长,是因为
3745
- * 这类 bug 编译得过、跑得通、每一条用例都绿。**
3758
+ * 它看上去像是可有可无的一个。**删掉它,症状是下面这些 —— 而它编译得过、
3759
+ * 跑得通、每一条用例都绿**,所以判据写得比修法长:
3746
3760
  *
3747
- * {@link collectSecurity} 里那句 `perms.level()` 读的是 {@link PermissionsView},
3748
- * 也就是 `WebRuntimeView.permissions`。runtime 的 `buildPermissionsControl()`
3749
- * 把 `level` / `setLevel` 两格**整个转发给引导会话那一份**(那边逐字是
3750
- * `level: () => bootstrap?.level() ?? services.config.permission`)。
3761
+ * 少了它,{@link collectSecurity} 里那句 `perms.level()` 读的是
3762
+ * {@link PermissionsView}(`WebRuntimeView.permissions`),而 runtime 的
3763
+ * `buildPermissionsControl()` 把 `level` / `setLevel` 两格**整个转发给引导会话
3764
+ * 那一份**(逐字是 `level: () => bootstrap?.level() ?? services.config.permission`)。
3751
3765
  * 于是浏览器看着会话 B 时,安全中心那一格印的是会话 A 那一档。
3752
3766
  *
3753
3767
  * 这和 2026-08-15 治掉的 `config.workspace` 是同一类病(方案 30 §10.4 第 1 条):
3754
3768
  * **端点按会话取,载荷里那一格却是进程级的。** 契约那边早把它说死了 ——
3755
3769
  * `WirePermissionState.level` 的 JSDoc 逐字写着「安全中心那一格和底栏这一格印的
3756
- * **必须是同一个名词**」,而底栏那一格从 2026-08-16 起按会话取
3757
- * ([permission.ts](./permission.js) 的 `permissionState()`)。今天这两格能不一样。
3770
+ * **必须是同一个名词**」,而底栏那一格按会话取
3771
+ * ([permission.ts](./permission.js) 的 `permissionState()`)。
3758
3772
  *
3759
3773
  * ⚠️ **代价不止是印错一个词。** 那一屏顶上四张状态卡的第一条纪律是「取最差的
3760
3774
  * 那一处」,而档位那张卡的色带就照这个值算:会话 B 在 `bypass` 上、引导会话在
3761
3775
  * `default` 上时,**那张卡是绿的**。一屏「我现在安不安全」的界面,在最该发黄的
3762
3776
  * 那一刻答了别人的状态。
3763
3777
  *
3764
- * **缺的那一段只有一处,而它在 [api.ts](./api.ts) 里,那个文件这一轮不归我改**
3765
- * (✅ 这一段就是收掉的那一笔,`getSecurity()` 里现在逐字是这个 diff):
3766
- *
3767
- * ```diff
3768
- * // api.ts / getSecurity()
3769
- * const payload = collectSecurity(
3770
- * ctx.runtime,
3771
- * ctx.runtime.workspaces,
3772
- * ctx.runtime.workspaces.of(sessionId),
3773
- * + ctx.runtime.sessionFactory.get(sessionId)?.permissions ?? null,
3774
- * );
3775
- * ```
3776
- *
3777
- * 这一层跟着收第四个参数
3778
- * `session: Pick<SessionPermissionsView, 'level'> | null`(类型在
3779
- * [sessions/scope.ts](./sessions/scope.js),**那一层已经镜像好了,不用动
3780
- * core / runtime**),投影里 `level` 改成 `session?.level() ?? perms.level()` ——
3781
- * 这个进程没在跑那段会话(只剩历史 / 被冷却)时退回进程那一份,那仍然是一句
3782
- * 真话,判据逐字同 `permissionState()` 那条兜底。
3778
+ * ⚠️ **这个参数是必填的,不是可选的**,`null` 由调用方显式写出来。回落发生在投影里
3779
+ * (`session?.level() ?? perms.level()` —— 这个进程没在跑那段会话时退回进程那一份,
3780
+ * 那仍然是一句真话,判据同 `permissionState()` 那条兜底),**而不是发生在
3781
+ * 「忘了传」这件事上**。区别不在行为上(都退回进程那一份),在于**下一个新加
3782
+ * 调用点的人会不会被编译器拦住**:可选参数不拦,必填参数拦。类型在
3783
+ * [sessions/scope.ts](./sessions/scope.js),core / runtime 不用动。
3783
3784
  *
3784
3785
  * ⚠️ **`rules` / `shadows` / `managed` / `audit` / `cached` 五样一个都不许跟着搬。**
3785
3786
  * 判据在 core 的 `permission/shared.ts`:它们**没有**跟着会话分家。搬过去的
3786
3787
  * 具体代价是这一屏只剩下这一个会话判过的那几行 ——
3787
3788
  * **一本漏了行的安全流水比没有账更坏,因为它看起来是完整的。**
3788
- *
3789
- * ⚠️ 这一轮**刻意没有**顺手加一个「不传就回落到进程那一份」的可选第四参。
3790
- * 那个形状编译得过、跑得通、只是答错会话,正是 `context.ts` 删掉进程级
3791
- * `checkpoints` 那一段点名的病;而且半接上的参数会让下一个人以为这条已经收了。
3792
- * 要么四个实参一起到位,要么这一格照旧是一处**写在文件头上的**缺陷。
3793
- *
3794
- * > ✅ **收的时候按这句办了:第四参是必填的,不是可选的。** 唯一的调用点
3795
- * > (`api.ts` 的 `getSecurity()`)传的是
3796
- * > `sessionFactory.get(sessionId)?.permissions ?? null` —— **`null` 是显式的**,
3797
- * > 由调用方写出来,不是省略出来的。回落发生在投影里
3798
- * > (`session?.level() ?? perms.level()`),而不是发生在「忘了传」这件事上。
3799
- * > 这两者的区别不在行为上(都退回进程那一份),在于**下一个新加调用点的人
3800
- * > 会不会被编译器拦住**:可选参数不拦,必填参数拦。
3801
3789
  */
3802
3790
 
3803
3791
  /**
@@ -4438,7 +4426,10 @@ declare function unavailableToHttp(lang?: Lang): {
4438
4426
  * 两边都可能单独存在,所以两边都不能当成必要条件:
4439
4427
  *
4440
4428
  * - **只有 Hub 有**:会话刚建出来、一条消息都还没落盘。DB 那半全 null,
4441
- * 但它必须出现在列表里 —— 否则用户新建一个会话,列表上什么都没有
4429
+ * 但它必须出现在列表里 —— 否则用户新建一个会话,列表上什么都没有。
4430
+ * ⚠️ 2026-09-20 起这一条有**唯一一个例外**:没人用过的那段引导会话
4431
+ * (判据在 `listSessionSummaries` 的 `unusedBootstrapId` 和下面那条 `fresh`
4432
+ * 通道上)。例外只此一个,而且划得掉的只有「Hub 里有、DB 里没有」那一半
4442
4433
  * - **只有 DB 有**:上次进程留下的会话,或者本进程里被冷却掉的。它照样要出现,
4443
4434
  * `live: false` 说明「Hub 手里没有它」,重新打开时走 `GET /messages` 回放
4444
4435
  *
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createServer } from 'http';
2
- import { createLogger, t, ApprovalRelay, QuestionRelay, serializeStream, withRoleScope, withModelScope, withToolScope, nextRunAt, observeBackgroundTasks, skillIndexDescription, SANDBOX_EXCLUDES, SANDBOX_COVERS, isSystemOpenable, backgroundTaskOutput, listBackgroundTasks, providerLabel, MAX_REASON_CHARS, MAX_OBJECTIVE_CHARS, MAX_GOAL_MAX_ROUNDS, DEFAULT_GOAL_MAX_ROUNDS, SCHEDULE_DEFAULT_ALLOWLIST, SCHEDULE_DEFAULTS, coalesceRecording, readRecording } from '@epoch-agent/runtime';
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
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, 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';
@@ -390,7 +390,7 @@ function toWireSummaryWorkspace(bound, live, row) {
390
390
  }
391
391
  return row?.workspaceState === "none" ? { state: "none" } : { state: "unknown" };
392
392
  }
393
- function listSessionSummaries(hubStates, catalog, query, workspaceOf = () => null, roleOf = () => null, limit = DEFAULT_SESSION_LIST_LIMIT, archived = false) {
393
+ function listSessionSummaries(hubStates, catalog, query, workspaceOf = () => null, roleOf = () => null, limit = DEFAULT_SESSION_LIST_LIMIT, archived = false, unusedBootstrapId = null) {
394
394
  const byId = new Map(hubStates.map((s) => [s.id, s]));
395
395
  if (query) {
396
396
  if (!catalog) return [];
@@ -417,7 +417,7 @@ function listSessionSummaries(hubStates, catalog, query, workspaceOf = () => nul
417
417
  })
418
418
  );
419
419
  const known = new Set(rows.map((r) => r.id));
420
- const fresh = archived ? [] : hubStates.filter((s) => !known.has(s.id)).filter((s) => catalog?.get(s.id)?.archived !== true).map(
420
+ const fresh = archived ? [] : hubStates.filter((s) => !known.has(s.id)).filter((s) => catalog?.get(s.id)?.archived !== true).filter((s) => s.id !== unusedBootstrapId).map(
421
421
  (s) => toWireSummary(s.id, s, null, { workspace: workspaceOf(s.id), role: roleOf(s.id) })
422
422
  );
423
423
  return [...fresh, ...summaries].sort(compareSummaries).slice(0, limit);
@@ -1074,6 +1074,11 @@ function toWireSkillBody(name, body) {
1074
1074
  const safe = Buffer.byteLength(cut, "utf-8") > MAX_SKILL_BODY_BYTES ? cut.slice(0, -1) : cut;
1075
1075
  return { name, body: safe, truncated: true, bytes };
1076
1076
  }
1077
+ function getRoles(ctx, res) {
1078
+ sendJson(res, 200, {
1079
+ roles: ctx.runtime.agentRoles.map((r) => toWireRole(r, ctx.runtime.agentRoleNotices))
1080
+ });
1081
+ }
1077
1082
  function getSkillBody(ctx, res, sessionId, name, lang) {
1078
1083
  if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
1079
1084
  return sendUnknownSession(res, sessionId);
@@ -1684,10 +1689,16 @@ function listSessions(ctx, res, query, _lang, archived = false) {
1684
1689
  (id) => ctx.runtime.workspaces.of(id),
1685
1690
  (id) => ctx.runtime.sessionFactory.get(id)?.role ?? null,
1686
1691
  void 0,
1687
- archived
1692
+ archived,
1693
+ unusedBootstrap(ctx)
1688
1694
  );
1689
1695
  sendJson(res, 200, { sessions });
1690
1696
  }
1697
+ function unusedBootstrap(ctx) {
1698
+ const { lastSessionId } = lastSessionField(ctx);
1699
+ if (!lastSessionId || lastSessionId === ctx.runtime.sessionId) return null;
1700
+ return ctx.runtime.sessionId;
1701
+ }
1691
1702
  function getSession(ctx, res, sessionId, lang) {
1692
1703
  const hub = ctx.hub.listSessions().find((one) => one.id === sessionId);
1693
1704
  const row = ctx.runtime.sessions?.get(sessionId) ?? null;
@@ -2759,7 +2770,15 @@ function toWireMarketplace(market) {
2759
2770
  source: market.source,
2760
2771
  entries: market.entries,
2761
2772
  addedAt: market.addedAt,
2762
- local: market.local
2773
+ local: market.local,
2774
+ categories: (market.categories ?? []).map(toWireCategory)
2775
+ };
2776
+ }
2777
+ function toWireCategory(node) {
2778
+ return {
2779
+ id: node.id,
2780
+ label: node.label ?? node.id,
2781
+ children: (node.children ?? []).map(toWireCategory)
2763
2782
  };
2764
2783
  }
2765
2784
  function toWirePreview2(preview) {
@@ -3334,7 +3353,7 @@ function numberList(value) {
3334
3353
  }
3335
3354
  return out;
3336
3355
  }
3337
- var CLEARABLE = ["model", "workDir", "startDate", "endDate"];
3356
+ var CLEARABLE = ["model", "role", "workDir", "startDate", "endDate"];
3338
3357
  function optionals(raw, allowClear, lang) {
3339
3358
  const out = {};
3340
3359
  const bad = (field) => ({
@@ -3384,11 +3403,13 @@ function optionals(raw, allowClear, lang) {
3384
3403
  }
3385
3404
  out.allowOperations = ops;
3386
3405
  }
3387
- if (raw["enabled"] !== void 0) {
3388
- if (typeof raw["enabled"] !== "boolean") {
3389
- return { ok: false, message: t("web.schedule_bad_field", { field: "enabled" }, lang) };
3406
+ for (const key of ["enabled", "showInSessionList"]) {
3407
+ const value = raw[key];
3408
+ if (value === void 0) continue;
3409
+ if (typeof value !== "boolean") {
3410
+ return { ok: false, message: t("web.schedule_bad_field", { field: key }, lang) };
3390
3411
  }
3391
- out.enabled = raw["enabled"];
3412
+ out[key] = value;
3392
3413
  }
3393
3414
  if (raw["permission"] !== void 0) {
3394
3415
  const level = raw["permission"];
@@ -3465,6 +3486,7 @@ function defaultsOf(ctx) {
3465
3486
  return {
3466
3487
  maxTurns: ctx.runtime.config.maxTurns,
3467
3488
  timeoutMs: SCHEDULE_DEFAULTS.timeoutMs,
3489
+ maxBudgetUsd: SCHEDULE_FORM_BUDGET_USD,
3468
3490
  allowTools: SCHEDULE_DEFAULT_ALLOWLIST.allowTools,
3469
3491
  allowOperations: SCHEDULE_DEFAULT_ALLOWLIST.allowOperations,
3470
3492
  allowRules: SCHEDULE_DEFAULT_ALLOWLIST.allowRules
@@ -4078,25 +4100,34 @@ async function pickNativeDirectory(opts) {
4078
4100
  }
4079
4101
  }
4080
4102
  var execFileRunner = (command, args, signal) => new Promise((done) => {
4081
- execFile(
4082
- command,
4083
- [...args],
4084
- {
4085
- ...signal ? { signal } : {},
4086
- encoding: "utf-8",
4087
- windowsHide: true,
4088
- maxBuffer: 1024 * 1024
4089
- },
4090
- (error, stdout, stderr) => {
4091
- const err = error;
4092
- done({
4093
- code: err?.code ?? 0,
4094
- stdout,
4095
- stderr,
4096
- missing: err?.code === "ENOENT",
4097
- message: err?.message ?? ""
4098
- });
4099
- }
4103
+ guardSpawn(
4104
+ () => execFile(
4105
+ command,
4106
+ [...args],
4107
+ {
4108
+ ...signal ? { signal } : {},
4109
+ encoding: "utf-8",
4110
+ windowsHide: true,
4111
+ maxBuffer: 1024 * 1024
4112
+ },
4113
+ (error, stdout, stderr) => {
4114
+ const err = error;
4115
+ done({
4116
+ code: err?.code ?? 0,
4117
+ stdout,
4118
+ stderr,
4119
+ missing: err?.code === "ENOENT",
4120
+ message: err?.message ?? ""
4121
+ });
4122
+ }
4123
+ ),
4124
+ (err) => done({
4125
+ code: err.code ?? 1,
4126
+ stdout: "",
4127
+ stderr: "",
4128
+ missing: err.code === "ENOENT" || err.code === "ENOTDIR",
4129
+ message: `${err.message} (command=${command})`
4130
+ })
4100
4131
  );
4101
4132
  });
4102
4133
  // src/workspace/dirs.ts
@@ -4426,15 +4457,19 @@ async function forgetWorkspace(ctx, sessionId, req, res, lang) {
4426
4457
  var GIT_TIMEOUT_MS = 1e4;
4427
4458
  var GIT_MAX_BUFFER = 16 * 1024 * 1024;
4428
4459
  function runGit(cwd, args) {
4460
+ if (!canProbeGit()) return Promise.resolve({ ok: false, error: "git unavailable" });
4429
4461
  return new Promise((resolve4) => {
4430
- execFile(
4431
- "git",
4432
- [...args],
4433
- { cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BUFFER, encoding: "buffer" },
4434
- (err, stdout) => {
4435
- if (err) return resolve4({ ok: false, error: err.message });
4436
- resolve4({ ok: true, stdout });
4437
- }
4462
+ guardSpawn(
4463
+ () => execFile(
4464
+ "git",
4465
+ [...args],
4466
+ { cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BUFFER, encoding: "buffer" },
4467
+ (err, stdout) => {
4468
+ if (err) return resolve4({ ok: false, error: err.message });
4469
+ resolve4({ ok: true, stdout });
4470
+ }
4471
+ ),
4472
+ (err) => resolve4({ ok: false, error: `${err.message} (cwd=${cwd})` })
4438
4473
  );
4439
4474
  });
4440
4475
  }
@@ -4911,6 +4946,9 @@ async function handleApi(opts, req, res, method, segments, lang) {
4911
4946
  if (resource === "mcp" && first === void 0 && method === "POST") {
4912
4947
  return addMcp(ctx, req, res, lang);
4913
4948
  }
4949
+ if (resource === "roles" && first === void 0 && isRead(method)) {
4950
+ return getRoles(ctx, res);
4951
+ }
4914
4952
  if (resource === "roles" && first === void 0 && method === "POST") {
4915
4953
  return addRole(ctx, req, res, lang);
4916
4954
  }