@epoch-agent/server 0.21.0 → 0.22.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/README.md CHANGED
@@ -1694,14 +1694,21 @@ role 上。
1694
1694
  3. **[bind.ts](src/bind.ts) 那条「非回环不给 token 就拒绝启动」不放松**,也不为这个
1695
1695
  字段开例外
1696
1696
 
1697
- **绑定后不给改**(决定 18):中途换地盘会让前面那些工具调用里的 `./src/a.ts`
1697
+ **开过工之后不给改**(决定 18):中途换地盘会让前面那些工具调用里的 `./src/a.ts`
1698
1698
  指向另一个目录,而记录里没有任何地方写着从哪条开始换的。
1699
1699
 
1700
- ⚠️ **所以「换一个工作区」的唯一形态是新建一个会话** —— 2026-08-15 多会话工厂落地
1701
- 之后,`POST /api/sessions` 带另一个目录**不再是 409 `workspace-locked`**,而是
1702
- 建一个绑在那儿的新会话(见上面那张表)。那个码没有作废:它守的仍然是同一条规矩,
1703
- 只是这条路上再也走不到它了,真正会撞到它的是 runtime 那一层
1704
- (`SessionWorkspaces.bind`)。
1700
+ ⚠️ **2026-09-30:这条锁的边界从「绑过没有」挪到了「开过工没有」。** 判据就是上面
1701
+ 那句话自己 —— 还没开过工就**一条工具调用都没有**,锁在那段窗口里没有对象。
1702
+ 所以「挑一个 → 想换一个」在一段还没发过消息的会话上是允许的,而开过工之后照旧
1703
+ 一个字不能改。判据只有一份(`WorkspaceControl.canChange`),三处共用:runtime 里
1704
+ `bind()` 那道 `locked`、这条端点那道 409 闸、以及网线上给界面的 `changeable`。
1705
+ 三处各判一次的表现是「界面画着一张 picker、按下去 409」。
1706
+
1707
+ ⚠️ **所以「换一个工作区」的唯一形态**(开过工之后)**仍然是新建一个会话**
1708
+ —— 2026-08-15 多会话工厂落地之后,`POST /api/sessions` 带另一个目录**不再是
1709
+ 409 `workspace-locked`**,而是建一个绑在那儿的新会话(见上面那张表)。那个码没有
1710
+ 作废:它守的仍然是同一条规矩,只是这条路上再也走不到它了,真正会撞到它的是
1711
+ runtime 那一层(`SessionWorkspaces.bind`)和这条端点那个 409 闸。
1705
1712
 
1706
1713
  ### 「这个会话绑在哪儿」是一条独立端点(2026-08-15)
1707
1714
 
@@ -1721,6 +1728,11 @@ role 上。
1721
1728
  | 建好了、**还没选**地盘 | 200 + `{binding:{state:'unbound'}}` |
1722
1729
  | 这个进程手里**没有**这段会话 | 409 `not-live-session` |
1723
1730
 
1731
+ ⚠️ **`bound` / `none` 还各带一格 `changeable`**(2026-09-30):这次决定还能不能改,
1732
+ 判据只有一条 —— 这段会话**开过工没有**(和上面那道 409 闸、以及 runtime 里
1733
+ `bind()` 那道 `locked` 读的是同一个方法,见下面 POST 那一节)。`unbound` 不带它:
1734
+ 那一档还没有决定,一直可挑。这一格是**界面画菜单还是画 diff 门**的依据。
1735
+
1724
1736
  第 3 行和第 5 行是这条端点原本全部的难点。绑定活在进程内存里、**不落盘**,所以上
1725
1737
  一个进程留下的会话在 `workspaces.of()` 那儿也是 `null` —— 和「这个会话真的不使用
1726
1738
  工作区」在数据上一模一样。合成一个的表现很具体:一段昨天在某个仓库里干了一整轮活
@@ -1807,27 +1819,35 @@ body: {root: '<绝对路径>'} | {none: true}
1807
1819
  「宿主看得见的那一面」上,路径校验、信任闸门、`locked` 幂等三样一个字都没动 ——
1808
1820
  这条端点只是第二个调用点。
1809
1821
 
1810
- **只有 `unbound` 收得动:**
1822
+ **只有 `unbound`,或者还没开过工的已定会话,收得动:**
1823
+
1824
+ | 这个会话此刻 | 结果 |
1825
+ | -------------------------------- | ----------------------------------------------------- |
1826
+ | `unbound` 且 idle | 绑 / 转 `none`,200 回**改完之后**的整份 binding |
1827
+ | `unbound` 但在跑 | 409 `busy` —— 等这一轮结束再挑(2026-08-20) |
1828
+ | `bound` / `none`,**还没开过工** | 同上第一行 —— 换一个「还没被任何工具调用用上的选择」 |
1829
+ | `bound` / `none`,**开过工了** | 409 `workspace-locked` —— 决定 18「开过工之后不给改」 |
1830
+ | 上面任一档,但这一轮在跑 | 409 `busy` |
1811
1831
 
1812
- | 这个会话此刻 | 结果 |
1813
- | ----------------- | ---------------------------------------------------- |
1814
- | `unbound` 且 idle | 绑 / 转 `none`,200 回**改完之后**的整份 binding |
1815
- | `unbound` 但在跑 | 409 `busy` —— 等这一轮结束再挑(2026-08-20) |
1816
- | `bound` | 409 `workspace-locked` —— 决定 18「绑定后不给改」 |
1817
- | `none` | 409 `workspace-locked` —— **「不使用」也是一次决定** |
1832
+ ⚠️ 第三行 2026-09-30 才有(判据在 `WorkspaceControl.canChange` 上,读的是会话库
1833
+ ——「这段会话开过工没有」)。**它不是放宽**:那条锁要护的是「前面那些工具调用里的
1834
+ 相对路径」,而那段窗口里一条都没有。
1818
1835
 
1819
- ⚠️ 第二行和另外两行**不是同一句话**,别合并:`workspace-locked` 说的是「已经定了,
1820
- 永远不能改」(下一步是新建会话),`busy` 说的是「现在不行,等一下」(下一步还是
1821
- 同一个按钮)。合成一句的话,用户会去建一个他不需要的会话。它是迟绑那一轮
1822
- (2026-08-20)补的:绑定这一下现在会真的改引擎脚下的目录,而一次 `run()` 底下可能有
1836
+ ⚠️ `workspace-locked` 和 `busy` **不是同一句话**,别合并:前者说的是「已经定了,
1837
+ 不能改」(下一步是新建会话),后者说的是「现在不行,等一下」(下一步还是
1838
+ 同一个按钮)。合成一句的话,用户会去建一个他不需要的会话。`busy` 是迟绑那一轮
1839
+ (2026-08-20)补的:绑定这一下会真的改引擎脚下的目录,而一次 `run()` 底下可能有
1823
1840
  二十次工具调用 —— 半路换根等于同一轮里前几次按 A、后几次按 B 解析相对路径,而历史里
1824
1841
  那条 `[系统]` 留痕是**轮次开始时**插的,盖不住这一档。响应头带 `X-Epoch-Turn-State`,
1825
1842
  和 `POST /messages` 那条 409 同一对形状。
1826
1843
 
1827
- ⚠️ 第三行是这条端点唯一一处会被读错的地方:`none` 看着像「还空着」,但它是用户
1828
- **明确按过**的一档(决定 18 侧栏「任务」组的成员)。让它还能被改,等于给一段说过
1829
- 「不要地盘」的会话补一个地盘 —— 而它前面那些工具调用里的相对路径已经按服务进程的
1830
- 启动目录解析过了,判据逐字同 `bound` 那一行。**不做 `PATCH`,也不做换绑。**
1844
+ ⚠️ `none` 那一档是这条端点唯一一处会被读错的地方:它看着像「还空着」,但它是用户
1845
+ **明确按过**的一档(决定 18 侧栏「任务」组的成员)。**开过工之后**让它还能被改,
1846
+ 等于给一段说过「不要地盘」的会话补一个地盘 —— 而它前面那些工具调用里的相对路径
1847
+ 已经按服务进程的启动目录解析过了,判据逐字同 `bound` 那一行。而还没开过工时它和
1848
+ `bound` 一样放行:底栏那张菜单里「不使用工作区」和「换一个目录」本来就是同一层的
1849
+ 两项,一项能改、另一项点了没反应,用户看到的是「菜单里有一项坏了」。
1850
+ **不做 `PATCH`,也不做换绑** —— 这条端点只是在那个窗口里允许「改一个还没用上的选择」。
1831
1851
 
1832
1852
  请求体**两支恰好一支**:两支都给 / 都不给一律 400 `bad-workspace`。「两支都给」
1833
1853
  不许挑一支执行 —— 那两支通向的是两段完全不同的会话。**这一层一次路径校验都不做**
package/dist/index.d.ts CHANGED
@@ -2094,19 +2094,36 @@ declare class SessionHub {
2094
2094
  *
2095
2095
  * 为一帧发给**零个**收件人的帧去改一份三边共用的契约,换不回任何东西。
2096
2096
  *
2097
- * ## 所以产地是两条端点,各自的判据
2097
+ * ## 所以产地是四条端点,各自的判据
2098
2098
  *
2099
2099
  * | 产地 | 什么时候发 | 为什么 |
2100
2100
  * | --- | --- | --- |
2101
2101
  * | `POST /api/sessions`(`workspace/handlers.ts`) | **只在 `outcome.created`** | 复用那一支没多出一行来,发了等于让所有标签页白重取一次 |
2102
2102
  * | `DELETE /api/sessions/:id`(`api.ts`) | 删成了就发 | 不发的话**别的**标签页会一直挂着一行已经没了的会话,点进去才拿 404 |
2103
2103
  * | `PATCH /api/sessions/:id`(`api.ts`,2026-09-16) | **只在 `archived` 真的翻了** | 列表默认不列归档的那几段,所以归档 = 少一行、取回 = 多一行。判据全文在那个调用点上 |
2104
+ * | `POST /api/sessions/:id/workspace`(`workspace/handlers.ts`,2026-09-30) | **只在那一行真的换了组的时候**(`bind` 那一支看 `outcome.changed`、「不使用工作区」那一支看调用前是不是已经是 `none`) | 它改的是那一行上的 `workspace` 格 —— 而侧栏「空间 / 任务 / 早先的会话」三组正是读它分出来的。判据全文在那个调用点上,和下面「置顶」那一档的分界见下一节 |
2104
2105
  *
2105
2106
  * ⚠️ 第三个产地**不在「进出 Hub」这条路上**,而上一版这段话正是按那条路数的
2106
2107
  * (原文「那是第一版……`hub-register-sites.test.ts` 已经钉住了会话只能从那三处
2107
2108
  * 变活」)。钉住的是**活性**,而这一帧的宾语是**列表端点答出来的那几行** ——
2108
2109
  * 两者在归档这一档上分叉:一段会话被收进归档时进出 Hub 的表一个字没动,
2109
2110
  * 可侧栏那份列表少了一行。别再拿「经不经过 Hub」当这一帧的判据。
2111
+ * ⚠️ 第四个产地(绑定)**同样不经过 Hub**,而且它比归档那一档更远:它在
2112
+ * 字面上不增不减任何一行,改的只是**其中一行上的一个格子**。
2113
+ *
2114
+ * ## ⚠️ 绑定那一档和「置顶不发」看着像,分界是「那句话会不会骗人」
2115
+ *
2116
+ * 绑定和置顶一样不改成员数,但**只有它发**:
2117
+ *
2118
+ * - 置顶只是**顺序旧一拍** —— 那一行还在列表里、点得开、内容也对,而按下去
2119
+ * 的那一页自己会重取。过期看得见,但**不会骗人**,所以不发(判据全文在
2120
+ * protocol 的 `sessions-changed` 上);
2121
+ * - 绑定把那一行**放进了错误的分组**:屏幕说「这段会话在 epoch-agent 里干活」,
2122
+ * 而它其实在 `lw` 里 —— 那句话是假的,而且**不会自己修**,得用户刷新页面、
2123
+ * 或者切到一段列表里没有的会话才把它捡回来(2026-09-30 用户报的正是这个)。
2124
+ *
2125
+ * 代价如实记:一次换绑 = **每个标签页**各重取一次整份列表(本机 loopback GET,
2126
+ * 50 行封顶)。绑定是用户手动、低频的一次动作,不在一轮里反复发生,认下它。
2110
2127
  *
2111
2128
  * 另外两处刻意**不发**:
2112
2129
  *
@@ -3189,6 +3206,45 @@ interface PluginPreviewView {
3189
3206
  name: string;
3190
3207
  version: string;
3191
3208
  };
3209
+ /** `InstallDependency`(core,经 runtime 原样透出)的镜像 —— 见 `WirePluginDependency` */
3210
+ dependencies?: readonly DependencyView[];
3211
+ }
3212
+ /**
3213
+ * 一个依赖。**手写一份镜像**,理由同 {@link PluginPreviewView}:这一层只依赖
3214
+ * protocol + runtime,而 core 那个类型是经 runtime 透出来的,直接 import 会把
3215
+ * 分层绕过去(`check-layers` 会红)。
3216
+ *
3217
+ * ⚠️ 镜像的代价是**加一格要两边一起加**,而漏了的表现是「core 算了、界面没有」——
3218
+ * 所以 `toWireDependency` 是照着这个形状逐格写的,不是 spread 过去的。
3219
+ */
3220
+ interface DependencyView {
3221
+ name: string;
3222
+ status: 'installed' | 'plan' | 'unresolved';
3223
+ requiredBy: string;
3224
+ source?: string;
3225
+ reason?: string;
3226
+ versionUnmet?: {
3227
+ required: string;
3228
+ current: string;
3229
+ reason: string;
3230
+ };
3231
+ manifest?: {
3232
+ name: string;
3233
+ version: string;
3234
+ };
3235
+ inventory?: {
3236
+ commands: readonly string[];
3237
+ roles: readonly string[];
3238
+ skills: readonly string[];
3239
+ hooks: readonly {
3240
+ type: string;
3241
+ count: number;
3242
+ }[];
3243
+ denyRules: number;
3244
+ mcpServers: readonly string[];
3245
+ ignoredBuckets: readonly string[];
3246
+ jsTools: boolean;
3247
+ };
3192
3248
  }
3193
3249
  /** 七档拒绝的共同形状(runtime 的 `PluginRefusal`) */
3194
3250
  interface RefusalView {
@@ -3861,6 +3917,16 @@ interface WorkspaceView {
3861
3917
  decideNone(sessionId: string): void;
3862
3918
  /** 这个会话是哪一档。**不答「这个进程认不认识它」**,那一问由 Hub 答 */
3863
3919
  stateOf(sessionId: string): WorkspaceBindingState;
3920
+ /**
3921
+ * 这次决定还能不能改(2026-09-30)。
3922
+ *
3923
+ * **两处问它,而且必须是同一个答案**:`bindWorkspace` 那道 409 闸,
3924
+ * 以及 GET / POST 两条路下发给界面的 `changeable`。三处各判一次的表现是
3925
+ * 「界面上画着一张 picker、按下去 409」—— 那句话读起来像功能坏了。
3926
+ *
3927
+ * 判据全文在 runtime 的 `WorkspaceControl.canChange` 上。
3928
+ */
3929
+ canChange(sessionId: string): boolean;
3864
3930
  release(sessionId: string): void;
3865
3931
  /** 本机「已知工作区」清单,最近使用的在前 */
3866
3932
  known(): ReadonlyArray<{
@@ -4993,6 +5059,26 @@ interface WebRuntimeView extends RuntimeCapabilities, RuntimeSecurity, RuntimeSe
4993
5059
  */
4994
5060
  session: (HubSession & ModelSessionFacts) | null;
4995
5061
  sessionId: string;
5062
+ /**
5063
+ * 不绑工作区时引擎**实际**跑在哪个目录(2026-09-30)。
5064
+ * `EpochRuntime.defaultWorkDir` 结构上满足它。
5065
+ *
5066
+ * ⚠️ **它是可选字段**,同 `diagnosticsIn?` / `lastOpenSession?` 那一条:
5067
+ * 自己拼 `WebRuntimeView` 的宿主可以不给 —— 那时 `GET /api/config` 整个不下发
5068
+ * `defaultWorkDir`,界面退回「选择工作区」那个纯动作(两种形态都画得出来)。
5069
+ * 把 `EpochRuntime` 整个传进来的宿主什么都不用做:它恒有这一格。
5070
+ *
5071
+ * ⚠️ **它是进程级的一份,所有会话共用**:绑定表答不出来时每个会话都回落到它
5072
+ * (`session-factory.ts` 的 `site()` 那一行 `now?.workspace.root ?? opts.defaultWorkDir`)。
5073
+ * 别读成「引导会话的工作目录」—— 引导会话只是**恰好**也跑在它上面。
5074
+ *
5075
+ * ⚠️ 在这儿**不要**用 `process.cwd()` 重算:宿主可以显式传 `workDir` 起引擎
5076
+ * (`buildRuntime({ workDir })`),而那是**宿主进程**的 cwd,两者不是一回事。
5077
+ *
5078
+ * 上网线的形态、以及它为什么非下发不可,在 protocol 的
5079
+ * `WireConfigResponse.defaultWorkDir` 上。
5080
+ */
5081
+ defaultWorkDir?: string;
4996
5082
  /**
4997
5083
  * 多会话工厂(方案 30 §6.3)。`EpochRuntime.sessionFactory` 结构上满足它。
4998
5084
  *
package/dist/index.js CHANGED
@@ -370,10 +370,13 @@ var EMPTY_INVENTORY = {
370
370
  policies: 0,
371
371
  settingsFiles: []
372
372
  };
373
- function toWireBinding(state, bound) {
374
- if (state === "bound" && bound)
375
- return { state: "bound", workspace: toWireSessionWorkspace(bound) };
376
- return { state: state === "bound" ? "unbound" : state };
373
+ function toWireBinding(state, bound, changeable) {
374
+ if (state === "bound") {
375
+ if (bound) return { state: "bound", workspace: toWireSessionWorkspace(bound), changeable };
376
+ return { state: "unbound" };
377
+ }
378
+ if (state === "none") return { state: "none", changeable };
379
+ return { state: "unbound" };
377
380
  }
378
381
  function toWireWorkspaceRef(bound) {
379
382
  if (!bound) return null;
@@ -1780,6 +1783,7 @@ function config(ctx, res, lang) {
1780
1783
  model: runtime.config.model,
1781
1784
  permission: runtime.config.permission,
1782
1785
  workspace: bound ? toWireSessionWorkspace(bound) : null,
1786
+ ...runtime.defaultWorkDir !== void 0 ? { defaultWorkDir: workspaceRefOf(runtime.defaultWorkDir) } : {},
1783
1787
  knownWorkspaces: toWireKnownWorkspaces(runtime.workspaces.known()),
1784
1788
  usageScope: runtime.usageScope,
1785
1789
  compression: runtime.compression,
@@ -2827,14 +2831,15 @@ function sessionWorkspace(ctx, res, sessionId, lang) {
2827
2831
  return sendUnknownSession(res, sessionId);
2828
2832
  }
2829
2833
  const state = ctx.runtime.workspaces.stateOf(sessionId);
2834
+ const changeable = ctx.runtime.workspaces.canChange(sessionId);
2830
2835
  if (state !== "none") {
2831
2836
  return sendJson(res, 200, {
2832
- binding: toWireBinding(state, ctx.runtime.workspaces.of(sessionId))
2837
+ binding: toWireBinding(state, ctx.runtime.workspaces.of(sessionId), changeable)
2833
2838
  });
2834
2839
  }
2835
2840
  if (ctx.hub.has(sessionId) || ctx.hub.isCooled(sessionId)) {
2836
2841
  return sendJson(res, 200, {
2837
- binding: { state: "none" }
2842
+ binding: { state: "none", changeable }
2838
2843
  });
2839
2844
  }
2840
2845
  sendError(res, 409, "not-live-session", t("web.workspace_not_live", void 0, lang));
@@ -2848,7 +2853,7 @@ async function bindWorkspace(ctx, req, res, sessionId, lang) {
2848
2853
  const parsed = readBindWorkspaceBody(asRecord(body.value), lang);
2849
2854
  if (!parsed.ok) return sendError(res, 400, "bad-workspace", parsed.message);
2850
2855
  const state = ctx.runtime.workspaces.stateOf(sessionId);
2851
- if (state !== "unbound") {
2856
+ if (state !== "unbound" && !ctx.runtime.workspaces.canChange(sessionId)) {
2852
2857
  return sendError(
2853
2858
  res,
2854
2859
  409,
@@ -2864,8 +2869,9 @@ async function bindWorkspace(ctx, req, res, sessionId, lang) {
2864
2869
  }
2865
2870
  if (parsed.kind === "none") {
2866
2871
  ctx.runtime.workspaces.decideNone(sessionId);
2872
+ if (state !== "none") ctx.hub.notifySessionsChanged(sessionId);
2867
2873
  return sendJson(res, 200, {
2868
- binding: { state: "none" }
2874
+ binding: { state: "none", changeable: ctx.runtime.workspaces.canChange(sessionId) }
2869
2875
  });
2870
2876
  }
2871
2877
  const outcome = ctx.runtime.workspaces.bind(sessionId, parsed.root, lang);
@@ -2873,8 +2879,13 @@ async function bindWorkspace(ctx, req, res, sessionId, lang) {
2873
2879
  const mapped = bindFailureToHttp(outcome, lang);
2874
2880
  return sendError(res, mapped.status, mapped.code, mapped.message);
2875
2881
  }
2882
+ if (outcome.changed) ctx.hub.notifySessionsChanged(sessionId);
2876
2883
  sendJson(res, 200, {
2877
- binding: { state: "bound", workspace: toWireSessionWorkspace(outcome.bound) }
2884
+ binding: {
2885
+ state: "bound",
2886
+ workspace: toWireSessionWorkspace(outcome.bound),
2887
+ changeable: ctx.runtime.workspaces.canChange(sessionId)
2888
+ }
2878
2889
  });
2879
2890
  }
2880
2891
  async function sessionDiff(ctx, res, sessionId, _lang) {
@@ -3415,7 +3426,30 @@ function toWirePreview2(preview) {
3415
3426
  mcpServers: [...inventory.mcpServers],
3416
3427
  ignoredBuckets: [...inventory.ignoredBuckets],
3417
3428
  hasJsTools: inventory.jsTools,
3418
- conflict: preview.conflict ? { name: preview.conflict.name, version: preview.conflict.version } : null
3429
+ conflict: preview.conflict ? { name: preview.conflict.name, version: preview.conflict.version } : null,
3430
+ dependencies: (preview.dependencies ?? []).map(toWireDependency)
3431
+ };
3432
+ }
3433
+ function toWireDependency(dep) {
3434
+ const inventory = dep.inventory;
3435
+ return {
3436
+ name: dep.name,
3437
+ status: dep.status,
3438
+ requiredBy: dep.requiredBy,
3439
+ source: dep.source ?? null,
3440
+ reason: dep.reason ?? null,
3441
+ version: dep.manifest?.version ?? null,
3442
+ versionUnmet: dep.versionUnmet ?? null,
3443
+ inventory: inventory ? {
3444
+ commands: [...inventory.commands],
3445
+ roles: [...inventory.roles],
3446
+ skills: [...inventory.skills],
3447
+ hooks: inventory.hooks.map((h) => ({ type: h.type, count: h.count })),
3448
+ denyRules: inventory.denyRules,
3449
+ mcpServers: [...inventory.mcpServers],
3450
+ ignoredBuckets: [...inventory.ignoredBuckets],
3451
+ hasJsTools: inventory.jsTools
3452
+ } : null
3419
3453
  };
3420
3454
  }
3421
3455
  function controlOf(ctx, res, lang) {