@chanlerdev/scorel 0.0.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.
Files changed (80) hide show
  1. package/README.md +110 -0
  2. package/dist/index.js +6675 -0
  3. package/dist/index.js.map +7 -0
  4. package/docs/CHANGELOG.md +12 -0
  5. package/docs/README.md +116 -0
  6. package/docs/ROADMAP.md +669 -0
  7. package/docs/SHIP.md +242 -0
  8. package/docs/spec/channels.md +156 -0
  9. package/docs/spec/client.md +326 -0
  10. package/docs/spec/daemon.md +408 -0
  11. package/docs/spec/events.md +423 -0
  12. package/docs/spec/extensions.md +255 -0
  13. package/docs/spec/relay.md +391 -0
  14. package/docs/spec/runtime.md +251 -0
  15. package/docs/spec/session.md +380 -0
  16. package/docs/spec/ship/S0001-docs-baseline.md +41 -0
  17. package/docs/spec/ship/S0002-package-skeleton.md +56 -0
  18. package/docs/spec/ship/S0003-protocol-contracts.md +49 -0
  19. package/docs/spec/ship/S0004-session-core.md +50 -0
  20. package/docs/spec/ship/S0005-runtime-loop.md +48 -0
  21. package/docs/spec/ship/S0006-embedded-daemon-client.md +51 -0
  22. package/docs/spec/ship/S0007-cli-alpha.md +49 -0
  23. package/docs/spec/ship/S0008-coding-tools.md +107 -0
  24. package/docs/spec/ship/S0009-code-discovery-tools.md +82 -0
  25. package/docs/spec/ship/S0010-todo-tool-and-cli.md +81 -0
  26. package/docs/spec/ship/S0011-coding-agent-alpha-smoke.md +110 -0
  27. package/docs/spec/ship/S0012-coding-tools-maturity.md +143 -0
  28. package/docs/spec/ship/S0013-local-daemon-protocol.md +57 -0
  29. package/docs/spec/ship/S0014-local-daemon-lifecycle.md +64 -0
  30. package/docs/spec/ship/S0015-local-attach-and-broadcast.md +58 -0
  31. package/docs/spec/ship/S0016-local-daemon-resync-smoke.md +60 -0
  32. package/docs/spec/ship/S0017-grep-files-output-mode.md +49 -0
  33. package/docs/spec/ship/S0018-daemon-entrypoint-smoke.md +48 -0
  34. package/docs/spec/ship/S0019-remote-transport-contract.md +59 -0
  35. package/docs/spec/ship/S0020-remote-websocket-server.md +56 -0
  36. package/docs/spec/ship/S0021-remote-websocket-client-transport.md +55 -0
  37. package/docs/spec/ship/S0022-remote-daemon-cli-lifecycle.md +60 -0
  38. package/docs/spec/ship/S0023-remote-control-e2e-validation.md +66 -0
  39. package/docs/spec/ship/S0024-remote-attach-interactive-stream.md +49 -0
  40. package/docs/spec/ship/S0025-remote-attach-session-event-view.md +57 -0
  41. package/docs/spec/ship/S0026-attach-project-cache-and-dual-seq-reconnect.md +87 -0
  42. package/docs/spec/ship/S0027-session-diagnostics-log.md +77 -0
  43. package/docs/spec/ship/S0028-client-attach-diagnostics-log.md +70 -0
  44. package/docs/spec/ship/S0029-project-index-for-session-lookup.md +119 -0
  45. package/docs/spec/ship/S0030-webui-product-intent.md +73 -0
  46. package/docs/spec/ship/S0031-daemon-projectslug-rule.md +72 -0
  47. package/docs/spec/ship/S0032-daemon-protocol-completion.md +123 -0
  48. package/docs/spec/ship/S0033-webui-skeleton-routing.md +92 -0
  49. package/docs/spec/ship/S0034-webui-device-settings.md +121 -0
  50. package/docs/spec/ship/S0035-webui-device-handshake.md +83 -0
  51. package/docs/spec/ship/S0036-webui-project-session-sync.md +70 -0
  52. package/docs/spec/ship/S0037-webui-chatbox-v1.md +97 -0
  53. package/docs/spec/ship/S0038-webui-cancel-multiclient.md +65 -0
  54. package/docs/spec/ship/S0039-webui-e2e-newchat.md +74 -0
  55. package/docs/spec/ship/S0040-webui-codex-visual-tokens.md +227 -0
  56. package/docs/spec/ship/S0041-webui-markdown-and-tool-block.md +248 -0
  57. package/docs/spec/ship/S0042-webui-streaming-ux-autoscroll.md +130 -0
  58. package/docs/spec/ship/S0043-startup-ergonomics.md +278 -0
  59. package/docs/spec/ship/S0044-webui-chatbox-rebuild.md +556 -0
  60. package/docs/spec/ship/S0045-webui-card-sidebar-and-session-fixes.md +469 -0
  61. package/docs/spec/ship/S0046-webui-empty-composer-and-lazy-session.md +428 -0
  62. package/docs/spec/ship/S0047-webui-project-hover-newchat-and-dynamic-greeting.md +176 -0
  63. package/docs/spec/ship/S0048-device-level-host-project-registry.md +253 -0
  64. package/docs/spec/ship/S0049-webui-add-project-directory-browser.md +217 -0
  65. package/docs/spec/ship/S0050-instruction-snapshot-and-agents-assembly.md +338 -0
  66. package/docs/spec/ship/S0051-harness-item-and-system-reminder.md +190 -0
  67. package/docs/spec/ship/S0052-follow-up-queue-and-dual-loop.md +195 -0
  68. package/docs/spec/ship/S0053-skill-index-and-skill-tool.md +252 -0
  69. package/docs/spec/ship/S0054-webui-running-message-behavior.md +72 -0
  70. package/docs/spec/ship/S0055-webui-composer-acceptance-and-queue-strip.md +68 -0
  71. package/docs/spec/ship/S0056-relay-and-hosted-webui-contract.md +106 -0
  72. package/docs/spec/ship/S0057-relay-service-protocol-skeleton.md +161 -0
  73. package/docs/spec/ship/S0058-host-outbound-relay-and-pair-command.md +138 -0
  74. package/docs/spec/ship/S0059-relay-transport-and-hosted-webui-connector.md +140 -0
  75. package/docs/spec/ship/S0060-relay-hosted-webui-e2e-validation.md +132 -0
  76. package/docs/spec/ship/S0060-relay-hosted-webui-e2e-validation.verification.md +90 -0
  77. package/docs/spec/ship/S0061-hosted-defaults-and-cli-command-surface.md +208 -0
  78. package/docs/spec/ship/S0062-npm-package-and-release-workflow.md +166 -0
  79. package/docs/spec/tools.md +173 -0
  80. package/package.json +51 -0
@@ -0,0 +1,391 @@
1
+ # Relay Proxy And Hosted Entry
2
+
3
+ > 上游:`architecture.md`、`decisions/007-relay-proxy-and-entry-routing.md`、`spec/client.md`、`spec/daemon.md`
4
+ > 主题:Hosted WebUI / GUI / CLI Entry 通过 Relay 连接用户 Device-level Host。
5
+
6
+ ---
7
+
8
+ ## 0. 定位
9
+
10
+ Relay 是 **authenticated proxy + authorization registry**。
11
+
12
+ Relay 只解决两件事:
13
+
14
+ 1. 让 Entry 和 Host 在公网不可直连时通过一个稳定服务相遇。
15
+ 2. 记录哪些 Entry 被允许通过 Relay 控制哪些 Device。
16
+
17
+ Relay 不是 hosted daemon,也不是新的后端领域层。Project、Session、Runtime、tool execution、JSONL、replay 和 resync authority 仍然只属于 Host。
18
+
19
+ ---
20
+
21
+ ## 1. 角色
22
+
23
+ ```text
24
+ Entry / WebUI / GUI / CLI
25
+ -> Relay
26
+ -> Host / Daemon
27
+ -> Project / Session / Runtime / JSONL
28
+ ```
29
+
30
+ | 角色 | 职责 |
31
+ |---|---|
32
+ | Entry | 用户操作入口。持有稳定 `clientId`,通过 direct WS 或 Relay 连接 Host。 |
33
+ | Relay | 聚合授权关系和在线路由。只转发 daemon wire payload,不解释 Scorel 业务语义。 |
34
+ | Host / Daemon | Device-level Host。拥有 Project Registry、Session JSONL、Runtime、工具和事件流。 |
35
+
36
+ 术语说明:
37
+
38
+ - `deviceId`:被控制的 Host / Daemon Device 身份。
39
+ - `clientId`:控制 Host 的 Entry 身份。Relay 讨论中也可称为 `entryId`,但协议和 JSONL 继续使用现有 `clientId` 字段。
40
+
41
+ ---
42
+
43
+ ## 2. 不变量
44
+
45
+ 1. Relay 不存 Project Registry。
46
+ 2. Relay 不存 Session JSONL。
47
+ 3. Relay 不存 prompt、tool result、provider response 或 runtime diagnostics。
48
+ 4. Relay 不做 replay、resync、context build 或 Runtime 调度。
49
+ 5. Relay durable state 只包含 identity metadata 和授权关系。
50
+ 6. Relay transient state 只包含 pair session、presence、socket route。
51
+ 7. Host 仍然是唯一 Session writer。
52
+ 8. `user_message.clientId` 表示发起该用户动作的 Entry。
53
+ 9. Direct WS 和 Relay 只替换 transport,不改变 Device -> Project -> Session 层级。
54
+
55
+ ---
56
+
57
+ ## 3. Relay 状态
58
+
59
+ ### 3.1 Durable Store
60
+
61
+ Relay 可以持久化:
62
+
63
+ ```typescript
64
+ type RelayDeviceRecord = {
65
+ deviceId: DeviceId;
66
+ devicePublicKey?: string;
67
+ label?: string;
68
+ createdAt: number;
69
+ updatedAt: number;
70
+ };
71
+
72
+ type RelayClientRecord = {
73
+ clientId: ClientId;
74
+ clientPublicKey?: string;
75
+ label?: string;
76
+ createdAt: number;
77
+ updatedAt: number;
78
+ };
79
+
80
+ type RelayBindingRecord = {
81
+ deviceId: DeviceId;
82
+ clientId: ClientId;
83
+ createdAt: number;
84
+ revokedAt?: number;
85
+ };
86
+ ```
87
+
88
+ V1 可以先把 public key 字段作为 identity contract 预留;具体签名和 key lifecycle 由后续实现 spec 收口。
89
+
90
+ ### 3.2 Transient State
91
+
92
+ Relay 内存态:
93
+
94
+ ```typescript
95
+ type RelayPresence = {
96
+ devices: Map<DeviceId, HostSocket>;
97
+ clients: Map<ClientId, EntrySocket[]>;
98
+ };
99
+
100
+ type PairSession = {
101
+ pairCode: string;
102
+ clientId: ClientId;
103
+ expiresAt: number;
104
+ };
105
+ ```
106
+
107
+ Pair session 是短期、一次性状态。Relay 重启后允许丢失。
108
+
109
+ ### 3.3 Forbidden State
110
+
111
+ Relay 不得持久化或缓存:
112
+
113
+ - Project / Project Registry
114
+ - Session summary / transcript / JSONL
115
+ - prompt / tool result / provider response
116
+ - Runtime state / context / diagnostics
117
+ - resync event cache / replay state
118
+
119
+ ---
120
+
121
+ ## 4. 配对
122
+
123
+ V1 使用 Entry-initiated pairing:
124
+
125
+ ```text
126
+ Entry
127
+ -> Relay: create_pair_session(clientId)
128
+ <- Relay: pairCode
129
+
130
+ User
131
+ -> Host: scorel pair <pairCode>
132
+
133
+ Host
134
+ -> Relay: redeem_pair(pairCode, deviceId)
135
+ -> Relay persists binding { deviceId, clientId }
136
+ ```
137
+
138
+ 配对语义:
139
+
140
+ - 用户在本机运行 `scorel pair <code>` 是授权动作;默认连接官方 Relay,自部署时用 `--relay <url>` 覆盖。
141
+ - Pair code 只用于创建 `deviceId -> clientId` 授权关系。
142
+ - Pair code 不是长期 secret。
143
+ - Pair code 过期或被使用后必须失效。
144
+
145
+ Host 可以在本地保存 authorized client allowlist。Relay binding 负责路由前置检查;Host allowlist 负责最终准入检查。V1 可以先由 Relay binding 承担准入,但正式 spec 应保留 Host-side allowlist 的长期方向。
146
+
147
+ ---
148
+
149
+ ## 5. 连接与路由
150
+
151
+ Relay 连接有两条物理 WebSocket:
152
+
153
+ ```text
154
+ Entry -> Relay
155
+ Host -> Relay
156
+ ```
157
+
158
+ Entry 连 Host 不需要再建立第三条网络连接。Relay 只在已有 socket 上做逻辑路由:
159
+
160
+ ```text
161
+ Entry socket + deviceId -> Relay -> Host socket
162
+ ```
163
+
164
+ ### 5.1 Presence
165
+
166
+ Host relay mode:
167
+
168
+ ```text
169
+ Host
170
+ -> load device identity
171
+ -> connect Relay
172
+ -> announce deviceId
173
+ -> Relay marks deviceId online
174
+ ```
175
+
176
+ Entry / hosted WebUI:
177
+
178
+ ```text
179
+ Entry
180
+ -> load stable clientId
181
+ -> connect Relay
182
+ -> announce clientId
183
+ -> Relay marks clientId online
184
+ ```
185
+
186
+ Presence 只表示在线,不代表授权。
187
+
188
+ ### 5.2 Routing Frame
189
+
190
+ Entry 发往 Relay:
191
+
192
+ ```typescript
193
+ type RelayClientPayload =
194
+ | ({ type: "connect" } & ConnectParams)
195
+ | ClientMessage;
196
+
197
+ type EntryToRelayFrame = {
198
+ deviceId: DeviceId;
199
+ payload: RelayClientPayload;
200
+ };
201
+ ```
202
+
203
+ Relay 检查:
204
+
205
+ ```text
206
+ binding exists for (deviceId, clientId)
207
+ deviceId is online
208
+ ```
209
+
210
+ Relay 发往 Host:
211
+
212
+ ```typescript
213
+ type RelayToHostFrame = {
214
+ clientId: ClientId;
215
+ payload: RelayClientPayload;
216
+ };
217
+ ```
218
+
219
+ Host 发回 Relay:
220
+
221
+ ```typescript
222
+ type HostToRelayFrame = {
223
+ clientId: ClientId;
224
+ payload: DaemonMessage;
225
+ };
226
+ ```
227
+
228
+ Relay 再把 payload 发回对应 Entry socket。
229
+
230
+ `RelayClientPayload` 包含现有 daemon `connect` handshake 和 `ClientMessage`。Relay 只做外层路由和授权,不解释业务 payload。`clientId` 是现有 daemon wire / JSONL 字段。它在 Relay 语境中就是 Entry identity。不要为 V1 额外引入 `entryId` 或 `routeId` 字段,除非实现证明现有 `clientId` 无法覆盖多 tab / 多连接的 return path。
231
+
232
+ ---
233
+
234
+ ## 6. DaemonClient 语义
235
+
236
+ Relay 是 transport adapter:
237
+
238
+ ```typescript
239
+ const transport = new RelayTransport({
240
+ relayUrl,
241
+ deviceId,
242
+ clientId,
243
+ });
244
+
245
+ const client = new DaemonClient(transport, options);
246
+ await client.connect(sessionId);
247
+ ```
248
+
249
+ Direct WS 与 Relay 的差异只在 transport 层:
250
+
251
+ ```text
252
+ Direct:
253
+ DaemonClient -> WsTransport -> Host
254
+
255
+ Relay:
256
+ DaemonClient -> RelayTransport -> Relay -> Host relay adapter -> Host
257
+ ```
258
+
259
+ 进入 Host 后,仍然是现有 Host API:
260
+
261
+ - `listProjects`
262
+ - `listSessions`
263
+ - `createSession`
264
+ - `sendMessage`
265
+ - `rewriteQueue`
266
+ - `cancel`
267
+ - `subscribeEvents`
268
+ - `resyncEvents`
269
+
270
+ Host 不应复制一套 Relay-only Project、Session 或 Runtime 逻辑。
271
+
272
+ ---
273
+
274
+ ## 7. WebUI 多设备模型
275
+
276
+ Hosted WebUI 可以添加很多 Device。每个 Device 可以有多个 connector:
277
+
278
+ ```typescript
279
+ type WebUiDevice = {
280
+ id: string;
281
+ remoteIdentity?: {
282
+ deviceId: DeviceId;
283
+ };
284
+ connectors: DeviceConnector[];
285
+ };
286
+
287
+ type DeviceConnector =
288
+ | { kind: "direct_ws"; url: string; token: string }
289
+ | { kind: "relay"; relayUrl: string; deviceId: DeviceId; clientId: ClientId };
290
+ ```
291
+
292
+ 规则:
293
+
294
+ - Device 是业务身份,connector 是可达路径。
295
+ - 同一 `deviceId` 通过 direct WS 和 Relay 都可达时,WebUI 应合并为一个 Device。
296
+ - `deviceId + projectId + sessionId` 是缓存 scope,不是 URL 或 connector。
297
+ - Relay 可以返回授权 Device 列表和在线状态;Project / Session 列表仍然必须通过 Host API 获取。
298
+
299
+ 连接选择:
300
+
301
+ 1. direct local WS healthy 时优先直连。
302
+ 2. direct 不可用且 Relay presence 在线时使用 Relay。
303
+ 3. 都不可用时显示离线缓存。
304
+
305
+ ---
306
+
307
+ ## 8. 重连与恢复
308
+
309
+ Relay 不做 Session replay。
310
+
311
+ 断线恢复:
312
+
313
+ ```text
314
+ Entry reconnects Relay
315
+ Host reconnects Relay
316
+ Entry sends existing DaemonClient.connect/resync through Relay
317
+ Host answers from JSONL / live buffer
318
+ ```
319
+
320
+ 恢复锚点仍然是 Host 语义:
321
+
322
+ - `persistentLastSeq`
323
+ - `streamLastSeq`
324
+
325
+ Relay 重启会丢失 presence 和 pair sessions,但 durable bindings 可以保留。Relay 重启不影响 Host JSONL authority。
326
+
327
+ ---
328
+
329
+ ## 9. 不做什么
330
+
331
+ - 不实现 hosted execution。
332
+ - 不要求 V1 必须有用户账号。
333
+ - 不把 Relay 变成 Project / Session 后端。
334
+ - 不让 Relay 解释 daemon wire payload。
335
+ - 不在 Relay 中实现 replay / resync / context build。
336
+ - 不在本 spec 定义细粒度 ACL。
337
+ - 不在本 spec 实现 desktop GUI、SSH bootstrap 或 HTTP API。
338
+
339
+ ---
340
+
341
+ ## 10. 目录结构
342
+
343
+ Relay service 是可部署产品入口,默认放在 `apps/relay`,不是新的领域包。
344
+
345
+ ```text
346
+ apps/
347
+ relay/
348
+ package.json
349
+ src/
350
+ index.ts # process entrypoint
351
+ server.ts # WebSocket / HTTP server bootstrap
352
+ store.ts # durable device/client/binding store adapter
353
+ pairing.ts # short-lived pair sessions
354
+ presence.ts # online device/client socket registry
355
+ routing.ts # deviceId/clientId authorization check and proxy routing
356
+ diagnostics.ts # relay-side logs without user payload
357
+
358
+ packages/
359
+ protocol/
360
+ src/relay.ts # Relay frame and record types
361
+ client/
362
+ src/relay-transport.ts
363
+ daemon/
364
+ src/relay/
365
+ host-client.ts # Host outbound Relay connection
366
+ pair.ts # scorel pair support helpers
367
+
368
+ apps/
369
+ cli/
370
+ src/relay-cli.ts # scorel pair support
371
+ src/relay-server-cli.ts # scorel relay serve
372
+ webui/
373
+ ... # direct connector + relay connector UI
374
+ ```
375
+
376
+ 分工规则:
377
+
378
+ - `apps/relay`:只包含 deployable Relay service 和它的 runtime wiring。
379
+ - `packages/protocol`:放 Relay frame、record、request/response 类型;保持 browser-safe、零 Node 依赖。
380
+ - `packages/client`:放 `RelayTransport`,实现现有 `DaemonTransport` contract。
381
+ - `packages/daemon`:放 Host outbound Relay adapter;它把 Relay frame 转回现有 Host handler。
382
+ - `apps/cli`:只放命令入口,例如 `scorel pair <code>`、`scorel host serve`、`scorel relay serve`。
383
+ - `apps/webui`:只放 connector UI、Device registry 和 `RelayTransport` 使用逻辑。
384
+
385
+ 不要创建顶层 `relay/` 目录。当前 workspace 只约定 `apps/*` 和 `packages/*`;顶层目录会绕开现有 monorepo 边界。
386
+
387
+ 不要默认创建 `packages/relay`。只有当 Relay server 逻辑需要被多个 deployable app 复用时,才把纯库部分提取为 `packages/relay-server` 或类似名字;V1 先把服务私有逻辑留在 `apps/relay/src`,避免过早抽象。
388
+
389
+ ---
390
+
391
+ *Relay 的产品价值是让多 Entry 和多 Device 通过一个稳定服务形成授权连接图;Scorel 的业务真相仍然在 Device-level Host。*
@@ -0,0 +1,251 @@
1
+ # ScorelRuntime — 执行引擎
2
+
3
+ > 上游:`architecture.md`、`spec/events.md`
4
+ > 主题:ScorelRuntime 是纯执行引擎,接收 context 输入,产出 RawRuntimeEvent 流。不持有状态,不负责持久化。
5
+
6
+ ---
7
+
8
+ ## 1. 设计目标
9
+
10
+ Runtime 只做一件事:**给定 context,驱动 LLM + 工具循环,产出事件流**。
11
+
12
+ 不管:
13
+ - 持久化(Daemon 通过 RuntimeBridge 负责)
14
+ - 状态管理(SessionTree 负责)
15
+ - 并发控制(SessionLane 负责)
16
+ - 事件分发(EventBroadcaster 负责)
17
+
18
+ 这种分离让 Runtime 可测试、可复用、职责单一。
19
+
20
+ ---
21
+
22
+ ## 2. 核心变化(相对早期设计)
23
+
24
+ | 之前 | 之后 |
25
+ |------|------|
26
+ | Runtime 管理 message history | Runtime 接收 context 作为输入 |
27
+ | Runtime 负责持久化 | Daemon 负责持久化(通过 RuntimeBridge) |
28
+ | Runtime 输出 ScorelEvent | Runtime 输出 RawRuntimeEvent(无 seq/id) |
29
+ | Runtime 有 `prompt()` / `loadMessages()` | Runtime 只有 `executeTurn(context)` |
30
+
31
+ ---
32
+
33
+ ## 3. 接口
34
+
35
+ ```typescript
36
+ interface ScorelRuntime {
37
+ /**
38
+ * 执行一轮。接收预构建的 context,返回 raw event generator。
39
+ * 内部处理工具循环直到 turn 结束。
40
+ */
41
+ executeTurn(
42
+ context: ScorelMessage[],
43
+ systemPrompt: string | undefined,
44
+ options: RuntimeTurnOptions
45
+ ): AsyncGenerator<RawRuntimeEvent, void, undefined>;
46
+
47
+ cancel(): void;
48
+ readonly running: boolean;
49
+
50
+ // 工具注册
51
+ registerTool(tool: ToolDefinition): void;
52
+ unregisterTool(name: string): void;
53
+ }
54
+ ```
55
+
56
+ ---
57
+
58
+ ## 4. RawRuntimeEvent(内部,不导出给消费者)
59
+
60
+ ```typescript
61
+ type RawRuntimeEvent =
62
+ | { type: "message_start"; role: "assistant" }
63
+ | { type: "text_delta"; delta: string }
64
+ | { type: "thinking_delta"; delta: string; blockIndex: number }
65
+ | { type: "tool_call_delta"; toolCallId: string; toolName?: string; delta: string }
66
+ | { type: "message_end"; message: AssistantMessage }
67
+ | { type: "tool_execution_start"; toolCallId: string; toolName: string; args: unknown }
68
+ | { type: "tool_execution_update"; toolCallId: string; partial: unknown }
69
+ | { type: "tool_execution_end"; toolCallId: string; toolName: string; result: ToolResultBlock; durationMs: number }
70
+ | { type: "turn_start" }
71
+ | { type: "turn_end"; usage?: Usage; stopReason?: string }
72
+ | { type: "error"; error: Error };
73
+ ```
74
+
75
+ RuntimeBridge 负责将 RawRuntimeEvent 转换为统一的 ScorelEvent(分配 id、seq、parentId)。
76
+
77
+ ---
78
+
79
+ ## 5. Persist 策略:有内容就 persist
80
+
81
+ **规则:只要已生成文本 > 0,无论中断原因都 persist partial。**
82
+
83
+ | 场景 | 行为 |
84
+ |---|---|
85
+ | LLM 正常完成 | persist,stopReason: "end_turn" |
86
+ | API 错误(有文本) | persist partial,stopReason: "error" |
87
+ | 用户 Cancel(有文本) | persist partial,stopReason: "cancelled" |
88
+ | 用户 Cancel(无文本) | 不 persist,broadcast message_cancelled (transient) |
89
+ | Daemon 崩溃 | 丢失进行中消息(初期可接受) |
90
+
91
+ Partial message 的 `partial: true` 标记告诉 UI 展示中断状态。buildContext 时当正常 assistant message 使用。
92
+
93
+ ---
94
+
95
+ ## 6. 每步完成立刻 persist
96
+
97
+ 不等整个 turn 结束。Agent loop 中每一步完成就写 JSONL:
98
+
99
+ ```
100
+ user message → 立刻 persist (进入 executeTurn 前)
101
+ assistant message_end → 立刻 persist
102
+ tool_result 完成 → 立刻 persist
103
+ ```
104
+
105
+ 这保证任何时刻断线,JSONL 都包含到该步骤为止的完整状态。
106
+
107
+ ---
108
+
109
+ ## 7. Tool Result:per-tool-call 逐条 persist,串行链
110
+
111
+ 一条 assistant message 含多个 tool_use 时,每个工具结果独立 persist 为一条 message event,串行排列(不分叉):
112
+
113
+ ```
114
+ e04: assistant (tool_use[read_a] + tool_use[read_b]) parentId: e03
115
+ e05: tool_result (for read_a) parentId: e04
116
+ e06: tool_result (for read_b) parentId: e05
117
+ ```
118
+
119
+ pi-ai 的 `transformMessages` 负责跨 provider 格式转换。用户级 rewind 只暴露到 user message 粒度——rewind 到 e04 之前 = e04/e05/e06 全部不在 context 中。
120
+
121
+ ---
122
+
123
+ ## 8. Cancel 时补 error tool_result
124
+
125
+ LLM 生成了 tool_use 但工具未执行/被中断时,必须补一条 error tool_result 避免 unmatched tool call:
126
+
127
+ | Cancel 时机 | 行为 |
128
+ |---|---|
129
+ | 工具正在执行 | 等当前工具原子完成 → persist 正常 result → 不发起下轮 LLM |
130
+ | 工具还没开始 | persist error tool_result: `{ isError: true, content: "Cancelled by user" }` |
131
+ | Assistant message partial(tool_use JSON 截断) | 只保留 text 部分 persist,不含不完整 tool_use → 不需要补 tool_result |
132
+
133
+ ---
134
+
135
+ ## 9. Steer + FollowUp 双队列
136
+
137
+ 借鉴 pi-mono agent-loop 模式:
138
+
139
+ | Queue | 消费时机 | 用途 |
140
+ |---|---|---|
141
+ | steeringQueue | 每次 tool 完成后、下次 LLM 调用前 | 中途插话("别改了") |
142
+ | followUpQueue | end_turn + steeringQueue 空 | 追加任务("顺便跑 tests") |
143
+
144
+ Loop 逻辑:
145
+ ```
146
+ while (true):
147
+ LLM call → response
148
+ if tool_use → execute tools → drain steeringQueue → continue
149
+ if end_turn → drain steeringQueue
150
+ → if empty → drain followUpQueue
151
+ → has messages → inject as user message, continue
152
+ → both empty → runtime_end
153
+ ```
154
+
155
+ ### 9.1 Steer 消息的存储与呈现
156
+
157
+ Steer message persist 为**独立 PersistentEvent**(role = "user",`meta.source = "steer"`)。
158
+
159
+ ```typescript
160
+ // steer persist 为独立 message event
161
+ {
162
+ type: "message",
163
+ id: "e14",
164
+ parentId: "e13", // 挂在当前 tool_result 链之后
165
+ message: {
166
+ role: "user",
167
+ content: "别改了,直接跑测试",
168
+ meta: { source: "steer" }
169
+ }
170
+ }
171
+ ```
172
+
173
+ **convertToLlm 行为**(由 EventTypeHandler 声明):
174
+
175
+ | 前面有 tool_result | 行为 |
176
+ |---|---|
177
+ | ✅ 有 | `merge_prev` — 合入前一条 tool_result content 末尾,用 `<system-reminder>` 包裹 |
178
+ | ❌ 没有(idle 状态) | `include` — 作为独立 user message |
179
+
180
+ LLM 最终看到的(工具循环中):
181
+ ```
182
+ tool_result: "文件内容...\n\n<system-reminder>\n别改了,直接跑测试\n</system-reminder>"
183
+ ```
184
+
185
+ LLM 最终看到的(idle 时):
186
+ ```
187
+ user: "别改了,直接跑测试"
188
+ ```
189
+
190
+ FollowUp 同理:`meta: { source: "followUp" }`。
191
+
192
+ ### 9.2 Steer 在 idle 时
193
+
194
+ - Runtime 空闲时收到 steer → 等同 send_message,直接触发新 turn
195
+ - Runtime 运行中收到 steer → 注入 steeringQueue,下轮开始时消费
196
+
197
+ ---
198
+
199
+ ## 10. RuntimeBridge(Daemon-owned)
200
+
201
+ RuntimeBridge 是 Daemon 与 Runtime 之间的桥接层。它在本 spec 中作为 integration contract 描述,因为它消费 RawRuntimeEvent;实现归属 `@scorel/daemon`,不能放进 `@scorel/core/runtime`。
202
+
203
+ Daemon-owned RuntimeBridge 负责:
204
+
205
+ 1. 持久化 user MessageEvent
206
+ 2. 从 tree 构建 context
207
+ 3. 调用 runtime.executeTurn(context) 获得 AsyncGenerator
208
+ 4. 将 raw events 转换为统一事件(预分配 id、分配 seq)
209
+ 5. 持久化 assistant MessageEvent
210
+ 6. 如有 tool calls → 执行工具 → 持久化 tool_result MessageEvent → 继续循环
211
+
212
+ ```typescript
213
+ interface RuntimeBridge {
214
+ readonly sessionId: SessionId;
215
+ readonly running: boolean;
216
+
217
+ /**
218
+ * 执行一轮对话。完整流程见上述 6 步。
219
+ */
220
+ executeTurn(userMessage: UserMessage, options?: SendOptions): Promise<void>;
221
+
222
+ cancel(): Promise<void>;
223
+ }
224
+ ```
225
+
226
+ ### 10.1 Runtime 与 Session 关系
227
+
228
+ - 一个 daemon 可以运行**多个 runtime**
229
+ - 每个 runtime 服务**一个 session**(1:1)
230
+ - 多个终端同时操作不同 session = 多个并发 runtime
231
+ - 同一 session 的多个 client 共享一个 runtime(串行化通过 SessionLane)
232
+
233
+ ---
234
+
235
+ ## 11. 初期范围与延后项
236
+
237
+ **初期落地**
238
+ - ScorelRuntime 接口 + executeTurn
239
+ - RawRuntimeEvent → ScorelEvent 转换(daemon-owned RuntimeBridge)
240
+ - Persist 策略(partial persist + per-step persist)
241
+ - Cancel 处理(补 error tool_result)
242
+ - steeringQueue + followUpQueue 双队列
243
+
244
+ **延后**
245
+ - 工具并行执行优化(初期串行安全优先)
246
+ - Runtime 资源限制(timeout per-turn、token budget)
247
+ - Subagent 工具(递归调用隔离上下文)
248
+
249
+ ---
250
+
251
+ *Runtime 是纯函数式执行引擎。给 context,出 events。状态、持久化、分发全部外置。*