@epoch-agent/server 0.20.1 → 0.21.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 +36 -0
- package/dist/index.d.ts +109 -7
- package/dist/index.js +92 -9
- package/dist/web/assets/index-BFBWGizD.js +378 -0
- package/dist/web/assets/index-RUis_1AY.css +1 -0
- package/dist/web/index.html +13 -7
- package/package.json +3 -3
- package/dist/web/assets/index-DB70ukf7.css +0 -1
- package/dist/web/assets/index-yz-IciZ8.js +0 -378
package/README.md
CHANGED
|
@@ -86,6 +86,7 @@ tarball 里真的有那些字节。
|
|
|
86
86
|
|
|
87
87
|
| 端点 | 说明 |
|
|
88
88
|
| ---------------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
|
89
|
+
| `POST /api/appearance` | `{theme?, lang?}` → 记下界面外观(**进程级、无会话**,见下) |
|
|
89
90
|
| `GET /api/health` | **不鉴权**,只回 `{ok, version}` |
|
|
90
91
|
| `GET /api/config` | 模型 / 权限级别 / 工作区 / 品牌 / 启动诊断 / 绑没绑在回环之外(`?lang=` 见下) |
|
|
91
92
|
| `GET /api/events` | SSE,负载是 `WireEnvelope` |
|
|
@@ -178,6 +179,41 @@ tarball 里真的有那些字节。
|
|
|
178
179
|
清单以外还要知道的六件事(鉴权两段式、`Origin` 校验、`seq` 与 `Last-Event-ID` 续传、
|
|
179
180
|
断连不等于人走了)写在 [docs/EMBEDDING.md §10](../../docs/EMBEDDING.md)。
|
|
180
181
|
|
|
182
|
+
### `POST /api/appearance` —— 界面外观偏好(2026-09-29)
|
|
183
|
+
|
|
184
|
+
```
|
|
185
|
+
POST /api/appearance {theme?: 'system'|'light'|'dark', lang?: 'system'|'zh'|'en'}
|
|
186
|
+
→ 200 {ok: true, theme?, lang?} ← 盘上现在那一份
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
**它治的是「用户选的外观活不过一次进程重启」**:主题和语言原来只写在浏览器的
|
|
190
|
+
`localStorage` 里,而它**按 origin 隔离** —— 嵌入宿主用 `port: 0` 时每次启动都是
|
|
191
|
+
一个新的 `http://127.0.0.1:<新端口>`,上次那份读不到(升级必然重启一次,
|
|
192
|
+
所以它在升级那里最显眼)。这一条把偏好落进 `~/.epoch/appearance.json`,
|
|
193
|
+
下一次启动由 `createWebServer()` 拼进首屏 URL(`?theme=&lang=`)。
|
|
194
|
+
|
|
195
|
+
四条边界:
|
|
196
|
+
|
|
197
|
+
- **无会话,而且只能是 POST。** 路径上没有 `:sessionId`(判据同
|
|
198
|
+
`PUT /api/last-session`:落点是**进程级**文件);非 GET 是硬要求 ——
|
|
199
|
+
`auth.ts` 的 Origin 校验只在非安全方法上要求,而这条路会往用户磁盘写文件。
|
|
200
|
+
- **载荷是补丁,所以不是 PUT。** 缺席的键 = **那一格别动**,不是「设成跟随系统」;
|
|
201
|
+
同一个 URL 上提交 `{theme}` 和 `{lang}` 得到两种结果,那不是幂等的全量替换。
|
|
202
|
+
- **读没有端点。** 读那条路**必须发生在第一帧之前**,所以它是 `server.url` 上的
|
|
203
|
+
两个 query 参数,而服务端拼的是「盘上那份压过 `ui` 初值」之后的结果。
|
|
204
|
+
⚠️ 每次首屏导航(那次换 cookie 的 303)都会按**当下**盘上那份重盖一遍 ——
|
|
205
|
+
`server.url` 是启动那一刻的常量,而盘上那份会变。
|
|
206
|
+
- **认不出来的值回 400,一个字节都不落盘。** 值域的判定在消费侧
|
|
207
|
+
(写入口 = 这里,读出口 = `storedUiPreset` 和内联脚本),存储那一层只做持久化 ——
|
|
208
|
+
手改出来的坏值读得出来、用不上、也不报错。第一方界面**不看看回执**
|
|
209
|
+
(它那一发是 fire-and-forget,写不进去就静默降级),那两档状态码是给排查的人
|
|
210
|
+
和自己画前端的宿主的:503 = 宿主没接这条路,500 = 写盘失败(带原话)。
|
|
211
|
+
|
|
212
|
+
宿主那一侧的完整语义(`ui` 的准确含义、那张三档对照表、为什么偏好不落在
|
|
213
|
+
`config.yaml`)在 [docs/EMBEDDING.md §9](../../docs/EMBEDDING.md);
|
|
214
|
+
存储本体和四条「为什么不塞进 `display.*`」的判据在
|
|
215
|
+
[core/src/appearance.ts](../core/src/appearance.ts)。
|
|
216
|
+
|
|
181
217
|
### `/api/host/*` —— 宿主把一句话送进输入框(2026-09-28)
|
|
182
218
|
|
|
183
219
|
```
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Lang, AgentRoleSource, WireRoleRemoveFailure, WireSkillImportFailure, WireSkillImportForm, WireSkillRemoveFailure, WireSkillStageFailure, WireProjectInventory, WireCapabilitiesResponse, WireSkillBodyResponse, EpochUserContent, AgentEvent, WireEnvelope, CustomCommandDef, ExpandedCommand, ModelRef, SetSelectionResult, WireCommandExpansion, WireCustomCommand, WireGoalPhase, WireGoalRefusal, WireTurnState, WireSessionFinish, ApprovalOutcome, QuestionAnswer, WireHubEvent, SessionSurfaceView, WireSessionReference, WireFileReference, ProviderType, ModelSelectionOrigin, WireModelCaveat, WireModelRejection, ContextBreakdown, PermissionLevel, WirePermissionRememberFailure, WirePluginSourceType, WirePluginVisibility, WirePluginStatus, WirePluginRefuseReason, WirePluginUploadForm, WirePluginUploadFieldName, WirePluginUploadFields, 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
|
-
import { SerializableApprovalRequest, SerializableQuestionRequest, RunningSchedule, ApprovalRevokeResult, LevelChangeResult, LastOpenSessionControl, WorkspaceFilesView } from '@epoch-agent/runtime';
|
|
1
|
+
import { AppearanceTheme, AppearanceLang, Lang, AgentRoleSource, WireRoleRemoveFailure, WireSkillImportFailure, WireSkillImportForm, WireSkillRemoveFailure, WireSkillStageFailure, WireProjectInventory, WireCapabilitiesResponse, WireSkillBodyResponse, EpochUserContent, AgentEvent, WireEnvelope, CustomCommandDef, ExpandedCommand, ModelRef, SetSelectionResult, WireCommandExpansion, WireCustomCommand, WireGoalPhase, WireGoalRefusal, WireTurnState, WireSessionFinish, ApprovalOutcome, QuestionAnswer, WireHubEvent, SessionSurfaceView, WireSessionReference, WireFileReference, ProviderType, ModelSelectionOrigin, WireModelCaveat, WireModelRejection, ContextBreakdown, PermissionLevel, WirePermissionRememberFailure, WirePluginSourceType, WirePluginVisibility, WirePluginStatus, WirePluginRefuseReason, WirePluginUploadForm, WirePluginUploadFieldName, WirePluginUploadFields, 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
|
+
import { SerializableApprovalRequest, SerializableQuestionRequest, RunningSchedule, ApprovalRevokeResult, LevelChangeResult, LastOpenSessionControl, AppearanceControl, WorkspaceFilesView } from '@epoch-agent/runtime';
|
|
3
3
|
import { ServerResponse } from 'node:http';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -15,6 +15,7 @@ import { ServerResponse } from 'node:http';
|
|
|
15
15
|
* 整个文件是纯函数 —— 判定不能藏在 CLI 的 action 回调里,那种地方写不了用例,
|
|
16
16
|
* 而这条判定恰恰是验收第 12 / 13 条。
|
|
17
17
|
*/
|
|
18
|
+
|
|
18
19
|
/** 默认端口。4400 是方案里举的那个,没被常见服务占用 */
|
|
19
20
|
declare const DEFAULT_WEB_PORT = 4400;
|
|
20
21
|
/** 默认监听地址 —— 只有回环。改这个默认值需要重读 §6.1 */
|
|
@@ -68,10 +69,15 @@ declare const UI_LANG_PARAM = "lang";
|
|
|
68
69
|
*
|
|
69
70
|
* `'system'` 不是「不给」—— 它是明确的第三档(跟随系统),和不传这个字段
|
|
70
71
|
* (用户上次自己选的那个还算数)是两件事。
|
|
72
|
+
*
|
|
73
|
+
* ⚠️ **2026-09-29 起它从 protocol 派生**(`AppearanceTheme`),不再在这里
|
|
74
|
+
* 自己写一份字面量:同一个值域现在有四个消费者(界面 / 这儿的首屏 URL /
|
|
75
|
+
* 引擎落盘那份 / 宿主这个入参),而 `web` 是 private 包、`core` 不许依赖它 ——
|
|
76
|
+
* 除了 protocol 没有第三处放得下。判据在 protocol 的 `wire-appearance.ts` 文件头。
|
|
71
77
|
*/
|
|
72
|
-
type UiThemePref =
|
|
78
|
+
type UiThemePref = AppearanceTheme;
|
|
73
79
|
/** 同上,对应 `localStorage['epoch.lang']`。值域跟着 protocol 的 `LANGS` 走 */
|
|
74
|
-
type UiLangPref =
|
|
80
|
+
type UiLangPref = AppearanceLang;
|
|
75
81
|
/**
|
|
76
82
|
* 宿主给界面的首屏初值(方案 54 §七)。
|
|
77
83
|
*
|
|
@@ -79,6 +85,13 @@ type UiLangPref = 'system' | 'zh' | 'en';
|
|
|
79
85
|
* 「外观」分区仍然改得动;下次启动宿主再给一次初值。刻意**没有**「强制主题、
|
|
80
86
|
* 界面上不许改」的开关:那是把宿主的偏好升成锁,而外观是今天用户在这张界面上
|
|
81
87
|
* 唯一能自己调的东西(方案 54 §七末尾 + §十一)。
|
|
88
|
+
*
|
|
89
|
+
* ⚠️⚠️ **2026-09-29 起它还多了一条:用户自己选过的那一档压得过它。**
|
|
90
|
+
* 上面那句「下次启动宿主再给一次初值」原来的意思是「宿主每次都给、给了就生效」,
|
|
91
|
+
* 而那个意思在「宿主用 `port: 0`」的现实下等于**用户的选择活不过一次重启**
|
|
92
|
+
* (localStorage 按 origin 隔离,每次都是新 origin)。现在首屏带的是
|
|
93
|
+
* {@link resolveUiPreset} 算出来的那一份:**盘上有就用盘上的,缺的那一格才轮到
|
|
94
|
+
* 这里**。所以 `ui` 的准确语义是「我的产品默认外观」,而不是「每次启动重置外观」。
|
|
82
95
|
*/
|
|
83
96
|
interface UiPreset {
|
|
84
97
|
theme?: UiThemePref;
|
|
@@ -89,8 +102,9 @@ interface UiPreset {
|
|
|
89
102
|
*
|
|
90
103
|
* @param port 必须是**实际监听**的端口(`--port 0` 时由内核分配),
|
|
91
104
|
* 不是命令行里那个
|
|
92
|
-
* @param ui
|
|
93
|
-
*
|
|
105
|
+
* @param ui 这次首屏该带哪几档。**调用方算完再传**({@link resolveUiPreset}:
|
|
106
|
+
* 用户选过的压过宿主初值)。一个键都没有就一个参数都不拼 ——
|
|
107
|
+
* URL 逐字节和以前一样(`epoch web` 没配 `ui` 时走的就是这条)
|
|
94
108
|
*/
|
|
95
109
|
declare function firstScreenUrl(host: string, port: number, token: string, ui?: UiPreset): string;
|
|
96
110
|
|
|
@@ -1753,6 +1767,20 @@ interface SessionHubOptions {
|
|
|
1753
1767
|
* 后事,等于把一条不变量的执行摊到每个调用点上,漏一处就是一次静默泄漏。
|
|
1754
1768
|
*/
|
|
1755
1769
|
onCooled?: (sessionId: string) => void;
|
|
1770
|
+
/**
|
|
1771
|
+
* 读某一段会话**此刻**的标题。答不出就 `null`(不在目录里 / DB 起不来)。
|
|
1772
|
+
*
|
|
1773
|
+
* 这是 `session-title-changed` 那一帧唯一的真源(2026-09-29)。Hub 自己不认识
|
|
1774
|
+
* 会话目录 —— 判据同 {@link SessionHubOptions.onCooled}:那是引擎那一侧的东西,
|
|
1775
|
+
* 而 Hub 在这条链上只负责发帧。
|
|
1776
|
+
*
|
|
1777
|
+
* ⚠️ **为什么读的是「此刻」**:标题在轮次开头就落库了(`core` 那句
|
|
1778
|
+
* `store.beginTurn()` → `appendMessage`),而这里是在 `drive()` 的**落盘节拍**
|
|
1779
|
+
* 上读 —— 两者都发生在这一轮跑完之前。那个节拍只在落盘进度真的往前走了才触发,
|
|
1780
|
+
* 所以读到 `null` 是常态(这一段还没有标题)。
|
|
1781
|
+
* **读到 `null` 就什么都不做**,不许拿它当「标题被清空了」发出去。
|
|
1782
|
+
*/
|
|
1783
|
+
readTitle?: (sessionId: string) => string | null;
|
|
1756
1784
|
}
|
|
1757
1785
|
/**
|
|
1758
1786
|
* `start()` 的结果。
|
|
@@ -1835,6 +1863,8 @@ declare class SessionHub {
|
|
|
1835
1863
|
private readonly clock;
|
|
1836
1864
|
/** 见 {@link SessionHubOptions.onCooled} */
|
|
1837
1865
|
private readonly onCooled;
|
|
1866
|
+
/** 见 {@link SessionHubOptions.readTitle} */
|
|
1867
|
+
private readonly readTitle;
|
|
1838
1868
|
private readonly registry;
|
|
1839
1869
|
private readonly sinks;
|
|
1840
1870
|
/**
|
|
@@ -2085,6 +2115,33 @@ declare class SessionHub {
|
|
|
2085
2115
|
* 而那一行**本来就在列表里**(列表的另一半来自 DB)。列表没变,不该发。
|
|
2086
2116
|
*/
|
|
2087
2117
|
notifySessionsChanged(sessionId: string): void;
|
|
2118
|
+
/**
|
|
2119
|
+
* 广播「某一行上的**标题**变了」(2026-09-29)。语义、为什么带载荷、以及
|
|
2120
|
+
* 「别的会话那一行也覆写得到」全在 protocol 的 `session-title-changed` 上。
|
|
2121
|
+
*
|
|
2122
|
+
* ## 两个产地
|
|
2123
|
+
*
|
|
2124
|
+
* | 产地 | 什么时候发 |
|
|
2125
|
+
* | --- | --- |
|
|
2126
|
+
* | {@link drive} 的落盘节拍 | 自动截出来的那个标题(`core` 拿首条 user 消息截的),一轮跑着跑着就长出来了 |
|
|
2127
|
+
* | `PATCH /api/sessions/:id`(`api.ts` 的 `patchSession`) | 用户手动改名 |
|
|
2128
|
+
*
|
|
2129
|
+
* ## 为什么自动那一支挂在落盘节拍上,而不是「user 帧发完就发」
|
|
2130
|
+
*
|
|
2131
|
+
* 标题是**跟着那条 user 行一起落的**(`core` 的 `manager.ts` 的 `appendMessage`)。
|
|
2132
|
+
* 落盘节拍正是「库里往前走了」这件事的采样点,而它已经在那儿了 —— 借它顺手读一次
|
|
2133
|
+
* 标题,比另开一个「哪一帧之后再读一次库」的判断便宜,也不会漏掉别的落盘路径。
|
|
2134
|
+
*
|
|
2135
|
+
* ⚠️ **它不许只发给「正看着这一段」的人**:这一帧的宾语是**一行**,而侧栏吃所有
|
|
2136
|
+
* 会话的帧(判据在 protocol 那一帧的 JSDoc 上)。`publish` 那一层本来就是扇出到
|
|
2137
|
+
* 所有连接,所以这里不需要额外处理 —— 记这一笔是为了别有人顺手加个过滤。
|
|
2138
|
+
*
|
|
2139
|
+
* ⚠️ **`announcedTitle` 的写点只有这一处**(读它的是 `drive()`):两个产地各更新
|
|
2140
|
+
* 一遍的话,「哪一格才是已经宣布过的那个」就有了两份实现,而它们只要有一处忘了
|
|
2141
|
+
* 更新,症状是**一轮跑完多发一帧内容一样的标题** —— 不致命、但没人会去查。
|
|
2142
|
+
* 会话可能已经不在 Hub 里(被冷却 / 从没注册过),那时这一格没地方记,照发。
|
|
2143
|
+
*/
|
|
2144
|
+
notifySessionTitleChanged(sessionId: string, title: string): void;
|
|
2088
2145
|
/**
|
|
2089
2146
|
* 广播「插件那张表变了」(2026-09-24)。语义和「为什么不带 `sessionId`」全在
|
|
2090
2147
|
* protocol 的 `plugins-changed` 上。
|
|
@@ -4624,6 +4681,15 @@ interface CatalogRow {
|
|
|
4624
4681
|
* 每一行都答得出。上面那两格可选说的是另一件事(v7 之前的老行没记过)。
|
|
4625
4682
|
*/
|
|
4626
4683
|
archived: boolean;
|
|
4684
|
+
/**
|
|
4685
|
+
* 被置顶了吗(2026-09-29 接下网线)。
|
|
4686
|
+
*
|
|
4687
|
+
* 和 `archived` **同一档来路、同一档理由**:`sessions.pinned` 从会话库 v1 起
|
|
4688
|
+
* 就在、`NOT NULL DEFAULT 0`,每一行都答得出,所以它是必填而不是可选。
|
|
4689
|
+
*
|
|
4690
|
+
* ⚠️ **两件事正交**:归档说「列不列」,置顶说「排哪儿」。一行可以两个都是真。
|
|
4691
|
+
*/
|
|
4692
|
+
pinned: boolean;
|
|
4627
4693
|
}
|
|
4628
4694
|
/** 一条 FTS5 命中 —— runtime 的 `SessionHit` 结构上满足它 */
|
|
4629
4695
|
interface CatalogHit {
|
|
@@ -4648,14 +4714,31 @@ interface SessionCatalog {
|
|
|
4648
4714
|
/**
|
|
4649
4715
|
* ⚠️ `archived` 是三态(方案 64 PR-2):不给 = 只列没归档的,
|
|
4650
4716
|
* `true` = **只**列归档的。判据同 runtime 的 `SessionControl.list`。
|
|
4717
|
+
*
|
|
4718
|
+
* `pinnedOnly: true` 只列置顶的那些(2026-09-29)。要它的是
|
|
4719
|
+
* {@link listSessionSummaries}:那段函数必须把「很久没动过、按时间排已经掉出
|
|
4720
|
+
* 这一页」的置顶会话单独取回来,否则用户置顶它就是白置顶。
|
|
4651
4721
|
*/
|
|
4652
4722
|
list(limit?: number, opts?: {
|
|
4653
4723
|
archived?: boolean;
|
|
4724
|
+
pinnedOnly?: boolean;
|
|
4654
4725
|
}): CatalogRow[];
|
|
4655
4726
|
get(sessionId: string): CatalogRow | null;
|
|
4656
4727
|
rename(sessionId: string, title: string): CatalogRow | null;
|
|
4657
4728
|
/** 收进归档 / 取回。**不是删除** —— 判据在 runtime 的 `SessionControl.setArchived` 上 */
|
|
4658
4729
|
setArchived(sessionId: string, archived: boolean): CatalogRow | null;
|
|
4730
|
+
/**
|
|
4731
|
+
* 置顶 / 取消置顶(2026-09-29)。
|
|
4732
|
+
*
|
|
4733
|
+
* 收成一个带布尔的方法,形状**逐字同** {@link setArchived}:调用点(web 那张
|
|
4734
|
+
* 行菜单)拿到的本来就是一个布尔,拆成 `pin()` / `unpin()` 两个只会让每个
|
|
4735
|
+
* 调用点自己写一次 `if`。
|
|
4736
|
+
*
|
|
4737
|
+
* ⚠️ **它不推 `updatedAt`**(置顶不是「活动」——判据在 runtime 的
|
|
4738
|
+
* `SessionControl.setPinned` 上),所以这一发不改这一行**按时间排**的位置。
|
|
4739
|
+
* 变的是排序里置顶那一档,判据在 {@link compareSummaries} 上。
|
|
4740
|
+
*/
|
|
4741
|
+
setPinned(sessionId: string, pinned: boolean): CatalogRow | null;
|
|
4659
4742
|
delete(sessionId: string): Promise<CatalogDeletion>;
|
|
4660
4743
|
search(query: string, limit?: number): CatalogHit[];
|
|
4661
4744
|
}
|
|
@@ -5128,6 +5211,19 @@ interface WebRuntimeView extends RuntimeCapabilities, RuntimeSecurity, RuntimeSe
|
|
|
5128
5211
|
* 回 503 —— **不是假装记下来了**(同 `sessions` 为 null 时那两条的口径)。
|
|
5129
5212
|
*/
|
|
5130
5213
|
lastOpenSession?: LastOpenSessionControl;
|
|
5214
|
+
/**
|
|
5215
|
+
* 界面外观偏好(主题 / 语言,2026-09-29)。`EpochRuntime.appearance`
|
|
5216
|
+
* 结构上正好满足它。判据(含「为什么不是 `config.yaml` 的 `display`」四条)
|
|
5217
|
+
* 在 core 的 `appearance.ts` 文件头。
|
|
5218
|
+
*
|
|
5219
|
+
* ## ⚠️ 可选,理由逐字同上面 `lastOpenSession`
|
|
5220
|
+
*
|
|
5221
|
+
* 自己手拼 `WebRuntimeView` 的嵌入宿主和 `__tests__/harness.ts` 那份假 runtime
|
|
5222
|
+
* 都是手拼的,写成必填等于这一笔改动把它们全打断。不给时:首屏 URL 上一个
|
|
5223
|
+
* 外观参数都不多拼、那次 303 也不重盖(行为逐字等于这一轮之前),
|
|
5224
|
+
* 而 `POST /api/appearance` 回 503 —— **不是假装记下来了**。
|
|
5225
|
+
*/
|
|
5226
|
+
appearance?: AppearanceControl;
|
|
5131
5227
|
/**
|
|
5132
5228
|
* `@:` 引用另一段会话那一片(方案 53 PR-4)。SQLite 起不来时为 null。
|
|
5133
5229
|
* `EpochRuntime.sessionReferences` 结构上正好满足它。
|
|
@@ -6122,7 +6218,13 @@ interface CreateWebServerOptions {
|
|
|
6122
6218
|
* `private: true` —— 宿主 import 不到,只能给 webview 挂一个 preload、
|
|
6123
6219
|
* 在页面脚本之前手写 localStorage。能用,但它依赖的是一个我们随时能改的名字。
|
|
6124
6220
|
*
|
|
6125
|
-
* ⚠️ **是初值,不是锁**,判据见 {@link UiPreset}
|
|
6221
|
+
* ⚠️ **是初值,不是锁**,判据见 {@link UiPreset}。⚠️⚠️ **2026-09-29 起它还有一个
|
|
6222
|
+
* 更容易读反的地方:用户自己选过的那一档压得过它。** 值不直接拼进 URL,而是先过
|
|
6223
|
+
* {@link resolveUiPreset}(`<homeDir>/appearance.json` 里那一份优先,缺的那一格
|
|
6224
|
+
* 才轮到这里)—— 所以准确的语义是「**我的产品默认外观**」,不是「每次启动重置外观」。
|
|
6225
|
+
* 这一条在宿主用 `port: 0` 时尤其要紧:localStorage 按 origin 隔离,
|
|
6226
|
+
* 每次启动都是一个新 origin,于是「宿主每次都给」曾经等于「用户的选择活不过一次
|
|
6227
|
+
* 重启」。判据全文在 docs/EMBEDDING.md §9「给 ui,别去写 localStorage」那一节。
|
|
6126
6228
|
*/
|
|
6127
6229
|
ui?: UiPreset;
|
|
6128
6230
|
/** 环形缓冲容量,缺省 512 */
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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
3
|
import { timingSafeEqual, randomBytes, randomUUID } from 'crypto';
|
|
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';
|
|
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, isAppearanceTheme, isAppearanceLang, 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';
|
|
@@ -129,12 +129,28 @@ function generateToken() {
|
|
|
129
129
|
}
|
|
130
130
|
var UI_THEME_PARAM = "theme";
|
|
131
131
|
var UI_LANG_PARAM = "lang";
|
|
132
|
+
function storedUiPreset(stored) {
|
|
133
|
+
const preset = {};
|
|
134
|
+
if (isAppearanceTheme(stored.theme)) preset.theme = stored.theme;
|
|
135
|
+
if (isAppearanceLang(stored.lang)) preset.lang = stored.lang;
|
|
136
|
+
return preset;
|
|
137
|
+
}
|
|
138
|
+
function resolveUiPreset(host, stored) {
|
|
139
|
+
return { ...host, ...storedUiPreset(stored) };
|
|
140
|
+
}
|
|
141
|
+
function uiPresetParams(ui) {
|
|
142
|
+
const params = [];
|
|
143
|
+
if (ui.theme) params.push([UI_THEME_PARAM, ui.theme]);
|
|
144
|
+
if (ui.lang) params.push([UI_LANG_PARAM, ui.lang]);
|
|
145
|
+
return params;
|
|
146
|
+
}
|
|
132
147
|
function firstScreenUrl(host, port, token, ui = {}) {
|
|
133
148
|
const shown = displayHost(host);
|
|
134
149
|
const authority = shown.includes(":") && !shown.startsWith("[") ? `[${shown}]` : shown;
|
|
135
150
|
const params = [`${WIRE_AUTH_TOKEN_PARAM}=${encodeURIComponent(token)}`];
|
|
136
|
-
|
|
137
|
-
|
|
151
|
+
for (const [name, value] of uiPresetParams(ui)) {
|
|
152
|
+
params.push(`${name}=${encodeURIComponent(value)}`);
|
|
153
|
+
}
|
|
138
154
|
return `http://${authority}:${port}/?${params.join("&")}`;
|
|
139
155
|
}
|
|
140
156
|
var HOST_COMPOSE_TTL_MS = 10 * 6e4;
|
|
@@ -262,6 +278,7 @@ var SessionRegistry = class {
|
|
|
262
278
|
turnStartedAt: null,
|
|
263
279
|
lastFinish: null,
|
|
264
280
|
persisted: null,
|
|
281
|
+
announcedTitle: null,
|
|
265
282
|
queue: [],
|
|
266
283
|
lastActiveTick: ++this.tick
|
|
267
284
|
});
|
|
@@ -442,6 +459,7 @@ function toWireSummary(id, hub, row, extra = {}) {
|
|
|
442
459
|
lastFinish: state.lastFinish,
|
|
443
460
|
role: extra.role ?? null,
|
|
444
461
|
archived: row?.archived ?? false,
|
|
462
|
+
pinned: row?.pinned ?? false,
|
|
445
463
|
...extra.snippet === void 0 ? {} : { snippet: extra.snippet }
|
|
446
464
|
};
|
|
447
465
|
}
|
|
@@ -474,7 +492,9 @@ function listSessionSummaries(hubStates, catalog, query, workspaceOf = () => nul
|
|
|
474
492
|
return hits;
|
|
475
493
|
}
|
|
476
494
|
const rows = catalog?.list(limit, archived ? { archived: true } : {}) ?? [];
|
|
477
|
-
const
|
|
495
|
+
const pinnedRows = archived ? [] : catalog?.list(limit, { pinnedOnly: true }) ?? [];
|
|
496
|
+
const listedIds = new Set(rows.map((r) => r.id));
|
|
497
|
+
const summaries = [...rows, ...pinnedRows.filter((r) => !listedIds.has(r.id))].map(
|
|
478
498
|
(row) => toWireSummary(row.id, byId.get(row.id), row, {
|
|
479
499
|
workspace: workspaceOf(row.id),
|
|
480
500
|
role: roleOf(row.id)
|
|
@@ -487,6 +507,7 @@ function listSessionSummaries(hubStates, catalog, query, workspaceOf = () => nul
|
|
|
487
507
|
return [...fresh, ...summaries].sort(compareSummaries).slice(0, limit);
|
|
488
508
|
}
|
|
489
509
|
function compareSummaries(a, b) {
|
|
510
|
+
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
|
|
490
511
|
if (a.live !== b.live) return a.live ? -1 : 1;
|
|
491
512
|
const at = a.updatedAt ?? Number.POSITIVE_INFINITY;
|
|
492
513
|
const bt = b.updatedAt ?? Number.POSITIVE_INFINITY;
|
|
@@ -499,6 +520,7 @@ var SessionHub = class {
|
|
|
499
520
|
idleGraceMs;
|
|
500
521
|
clock;
|
|
501
522
|
onCooled;
|
|
523
|
+
readTitle;
|
|
502
524
|
registry;
|
|
503
525
|
sinks = /* @__PURE__ */ new Set();
|
|
504
526
|
observers = /* @__PURE__ */ new Set();
|
|
@@ -512,6 +534,7 @@ var SessionHub = class {
|
|
|
512
534
|
this.idleGraceMs = opts.idleGraceMs ?? 3e4;
|
|
513
535
|
this.clock = opts.clock ?? Date.now;
|
|
514
536
|
this.onCooled = opts.onCooled;
|
|
537
|
+
this.readTitle = opts.readTitle;
|
|
515
538
|
this.registry = new SessionRegistry({
|
|
516
539
|
...opts.maxActiveSessions === void 0 ? {} : { maxActive: opts.maxActiveSessions },
|
|
517
540
|
...opts.maxQueuedMessages === void 0 ? {} : { maxQueued: opts.maxQueuedMessages }
|
|
@@ -661,6 +684,12 @@ var SessionHub = class {
|
|
|
661
684
|
if (this.closed) return;
|
|
662
685
|
this.publish(sessionId, { type: "sessions-changed" });
|
|
663
686
|
}
|
|
687
|
+
notifySessionTitleChanged(sessionId, title) {
|
|
688
|
+
if (this.closed) return;
|
|
689
|
+
const entry = this.registry.get(sessionId);
|
|
690
|
+
if (entry) entry.announcedTitle = title;
|
|
691
|
+
this.publish(sessionId, { type: "session-title-changed", title });
|
|
692
|
+
}
|
|
664
693
|
notifyPluginsChanged() {
|
|
665
694
|
if (this.closed) return;
|
|
666
695
|
this.publish(void 0, { type: "plugins-changed" });
|
|
@@ -732,6 +761,10 @@ var SessionHub = class {
|
|
|
732
761
|
if (through !== null && through !== entry.persisted?.id) {
|
|
733
762
|
entry.persisted = { id: through, seq: frame.seq };
|
|
734
763
|
}
|
|
764
|
+
const title = this.readTitle?.(sessionId) ?? null;
|
|
765
|
+
if (title !== null && title !== entry.announcedTitle) {
|
|
766
|
+
this.notifySessionTitleChanged(sessionId, title);
|
|
767
|
+
}
|
|
735
768
|
if (event.type === "finish") {
|
|
736
769
|
entry.lastFinish = toWireFinish(event, frame.ts, entry.turnStartedAt);
|
|
737
770
|
}
|
|
@@ -1834,9 +1867,11 @@ async function patchSession(ctx, req, res, sessionId, lang) {
|
|
|
1834
1867
|
const fields = asRecord(body.value);
|
|
1835
1868
|
const rawTitle = fields["title"];
|
|
1836
1869
|
const rawArchived = fields["archived"];
|
|
1870
|
+
const rawPinned = fields["pinned"];
|
|
1837
1871
|
const wantsTitle = rawTitle !== void 0;
|
|
1838
1872
|
const wantsArchive = typeof rawArchived === "boolean";
|
|
1839
|
-
|
|
1873
|
+
const wantsPinned = typeof rawPinned === "boolean";
|
|
1874
|
+
if (!wantsTitle && !wantsArchive && !wantsPinned) {
|
|
1840
1875
|
return sendError(res, 400, "bad-patch", t("web.patch_needs_field", void 0, lang));
|
|
1841
1876
|
}
|
|
1842
1877
|
const title = typeof rawTitle === "string" ? rawTitle.trim() : "";
|
|
@@ -1852,10 +1887,15 @@ async function patchSession(ctx, req, res, sessionId, lang) {
|
|
|
1852
1887
|
t("web.session_store_unavailable", void 0, lang)
|
|
1853
1888
|
);
|
|
1854
1889
|
}
|
|
1890
|
+
const titleBefore = wantsTitle ? catalog.get(sessionId)?.title ?? null : null;
|
|
1855
1891
|
let row = wantsTitle ? catalog.rename(sessionId, title) : catalog.get(sessionId);
|
|
1856
1892
|
const archivedBefore = row?.archived;
|
|
1857
1893
|
if (row && wantsArchive) row = catalog.setArchived(sessionId, rawArchived);
|
|
1894
|
+
if (row && wantsPinned) row = catalog.setPinned(sessionId, rawPinned);
|
|
1858
1895
|
if (!row) return sendUnknownSession(res, sessionId, lang);
|
|
1896
|
+
if (wantsTitle && row.title !== titleBefore) {
|
|
1897
|
+
ctx.hub.notifySessionTitleChanged(sessionId, row.title);
|
|
1898
|
+
}
|
|
1859
1899
|
if (wantsArchive && archivedBefore !== row.archived) ctx.hub.notifySessionsChanged(sessionId);
|
|
1860
1900
|
const hub = ctx.hub.listSessions().find((s) => s.id === sessionId);
|
|
1861
1901
|
sendJson(res, 200, {
|
|
@@ -2098,6 +2138,37 @@ function parseAttachmentNames(raw) {
|
|
|
2098
2138
|
if (!Array.isArray(raw)) return [];
|
|
2099
2139
|
return raw.filter((name) => typeof name === "string" && name.length > 0);
|
|
2100
2140
|
}
|
|
2141
|
+
async function postAppearance(ctx, req, res, lang) {
|
|
2142
|
+
const control = ctx.runtime.appearance;
|
|
2143
|
+
if (!control) {
|
|
2144
|
+
return sendError(res, 503, "no-appearance-store", t("web.appearance_unavailable", {}, lang));
|
|
2145
|
+
}
|
|
2146
|
+
const body = await readJsonBody(req, void 0, lang);
|
|
2147
|
+
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
2148
|
+
const raw = body.value;
|
|
2149
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
2150
|
+
return sendError(res, 400, "bad-request", t("web.appearance_bad_request", {}, lang));
|
|
2151
|
+
}
|
|
2152
|
+
const record = raw;
|
|
2153
|
+
const rawTheme = record["theme"];
|
|
2154
|
+
const rawLang = record["lang"];
|
|
2155
|
+
const theme = isAppearanceTheme(rawTheme) ? rawTheme : void 0;
|
|
2156
|
+
const langPref = isAppearanceLang(rawLang) ? rawLang : void 0;
|
|
2157
|
+
if (rawTheme !== void 0 && theme === void 0 || rawLang !== void 0 && langPref === void 0) {
|
|
2158
|
+
return sendError(res, 400, "bad-request", t("web.appearance_bad_request", {}, lang));
|
|
2159
|
+
}
|
|
2160
|
+
const failure = control.set({
|
|
2161
|
+
...theme === void 0 ? {} : { theme },
|
|
2162
|
+
...langPref === void 0 ? {} : { lang: langPref }
|
|
2163
|
+
});
|
|
2164
|
+
if (failure) return sendError(res, 500, "write-failed", failure(lang));
|
|
2165
|
+
const stored = control.get();
|
|
2166
|
+
sendJson(res, 200, {
|
|
2167
|
+
ok: true,
|
|
2168
|
+
...isAppearanceTheme(stored.theme) ? { theme: stored.theme } : {},
|
|
2169
|
+
...isAppearanceLang(stored.lang) ? { lang: stored.lang } : {}
|
|
2170
|
+
});
|
|
2171
|
+
}
|
|
2101
2172
|
async function revokeApprovalCache(ctx, req, res, sessionId, lang) {
|
|
2102
2173
|
if (!ctx.hub.has(sessionId)) return sendUnknownSession(res, sessionId, lang);
|
|
2103
2174
|
const perms = ctx.runtime.permissions;
|
|
@@ -5072,7 +5143,7 @@ async function dispatch(opts, req, res, lang) {
|
|
|
5072
5143
|
}
|
|
5073
5144
|
if (verdict.setCookie) res.setHeader("Set-Cookie", verdict.setCookie);
|
|
5074
5145
|
if (verdict.setCookie && isRead(method) && !pathname.startsWith("/api/")) {
|
|
5075
|
-
return redirectWithoutToken(res, req.url);
|
|
5146
|
+
return redirectWithoutToken(res, req.url, ctx.runtime.appearance);
|
|
5076
5147
|
}
|
|
5077
5148
|
const segments = splitPath(pathname);
|
|
5078
5149
|
if (segments[0] === "api") return handleApi(opts, req, res, method, segments, lang);
|
|
@@ -5101,6 +5172,9 @@ async function handleApi(opts, req, res, method, segments, lang) {
|
|
|
5101
5172
|
if (resource === "last-session" && first === void 0 && method === "PUT") {
|
|
5102
5173
|
return putLastSession(ctx, req, res, lang);
|
|
5103
5174
|
}
|
|
5175
|
+
if (resource === "appearance" && first === void 0 && method === "POST") {
|
|
5176
|
+
return postAppearance(ctx, req, res, lang);
|
|
5177
|
+
}
|
|
5104
5178
|
if (resource === "host" && first === "compose" && second === void 0 && method === "POST") {
|
|
5105
5179
|
return postHostCompose(ctx, req, res, lang);
|
|
5106
5180
|
}
|
|
@@ -5413,9 +5487,12 @@ function archivedFlag(rawUrl) {
|
|
|
5413
5487
|
const raw = new URL(rawUrl ?? "/", "http://127.0.0.1").searchParams.get("archived");
|
|
5414
5488
|
return raw === "1" || raw === "true";
|
|
5415
5489
|
}
|
|
5416
|
-
function redirectWithoutToken(res, rawUrl) {
|
|
5490
|
+
function redirectWithoutToken(res, rawUrl, store) {
|
|
5417
5491
|
const url = new URL(rawUrl ?? "/", "http://127.0.0.1");
|
|
5418
5492
|
url.searchParams.delete(WIRE_AUTH_TOKEN_PARAM);
|
|
5493
|
+
for (const [name, value] of uiPresetParams(storedUiPreset(store?.get() ?? {}))) {
|
|
5494
|
+
url.searchParams.set(name, value);
|
|
5495
|
+
}
|
|
5419
5496
|
res.writeHead(303, { ...baseHeaders(), Location: `${url.pathname}${url.search}` });
|
|
5420
5497
|
res.end();
|
|
5421
5498
|
}
|
|
@@ -5445,7 +5522,8 @@ async function createWebServer(opts) {
|
|
|
5445
5522
|
...opts.idleGraceMs === void 0 ? {} : { idleGraceMs: opts.idleGraceMs },
|
|
5446
5523
|
...opts.clock === void 0 ? {} : { clock: opts.clock },
|
|
5447
5524
|
...opts.maxActiveSessions === void 0 ? {} : { maxActiveSessions: opts.maxActiveSessions },
|
|
5448
|
-
onCooled: (sessionId) => opts.runtime.sessionFactory.release(sessionId)
|
|
5525
|
+
onCooled: (sessionId) => opts.runtime.sessionFactory.release(sessionId),
|
|
5526
|
+
readTitle: (sessionId) => opts.runtime.sessions?.get(sessionId)?.title || null
|
|
5449
5527
|
});
|
|
5450
5528
|
if (opts.runtime.session) hub.register(opts.runtime.session);
|
|
5451
5529
|
const unsubscribeTasks = observeBackgroundTasks((sessionId) => {
|
|
@@ -5469,7 +5547,12 @@ async function createWebServer(opts) {
|
|
|
5469
5547
|
return {
|
|
5470
5548
|
ok: true,
|
|
5471
5549
|
server: {
|
|
5472
|
-
url: firstScreenUrl(
|
|
5550
|
+
url: firstScreenUrl(
|
|
5551
|
+
binding.host,
|
|
5552
|
+
listened.port,
|
|
5553
|
+
binding.token,
|
|
5554
|
+
resolveUiPreset(opts.ui ?? {}, opts.runtime.appearance?.get() ?? {})
|
|
5555
|
+
),
|
|
5473
5556
|
port: listened.port,
|
|
5474
5557
|
host: binding.host,
|
|
5475
5558
|
token: binding.token,
|