pi2web 0.2.6 → 0.2.12
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 +15 -5
- package/dist/activity.js +83 -9
- package/dist/agents.js +244 -43
- package/dist/http/agent-routes.js +37 -3
- package/dist/http/utils.js +5 -1
- package/dist/http/web-assets.js +1 -0
- package/dist/mail-notifier.js +7 -2
- package/dist/metadata-store.js +14 -1
- package/dist/safe-pause.js +100 -0
- package/dist/sdk-backend.js +48 -3
- package/dist/server.js +52 -2
- package/dist/session-performance.js +139 -0
- package/dist/sotto-stt.js +217 -0
- package/dist/voice.js +2 -2
- package/dist/ws/agent-socket.js +2 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/packages/protocol/openapi.yaml +22 -1
- package/packages/protocol/websocket.schema.json +4 -3
- package/web/activity-view.js +49 -4
- package/web/app.js +764 -94
- package/web/collab.js +18 -4
- package/web/index.html +5 -2
- package/web/styles.css +8 -2
- package/web/voice-pcm.js +36 -0
package/README.md
CHANGED
|
@@ -110,11 +110,19 @@ pi2web \
|
|
|
110
110
|
- 摘要 LLM 使用流式 Chat Completions;Remote Pi 在完整句子出现后立即调用流式 TTS,因此无需等待整段摘要完成。
|
|
111
111
|
- Web UI 中点击“启用语音”后,会播放服务端推送的 PCM16 或 MP3 音频;新的 Agent 运行会取消该 Agent 尚未完成的播报。
|
|
112
112
|
- 多个 Session 接近同时完成时,语音摘要会进入全局队列依次播放,不会重叠;有 Session 名称时以“会话{name}已完成:”开头。
|
|
113
|
-
- 只有配置 `--voice-stt-model
|
|
113
|
+
- 只有配置 `--voice-stt-model`,或在 Web UI「配置 → Sotto 语音输入」开启 Sotto 时,才显示麦克风按钮;识别文字只插入输入框,不会自动发送。
|
|
114
114
|
- API key 通过 `--voice-api-key-env` 和 `--voice-summary-api-key-env` 指定环境变量名,避免把密钥放入命令行参数。
|
|
115
115
|
|
|
116
116
|
语音流目前通过已认证的 Agent WebSocket 发送,只会实时投递,不写入 Session 或事件 replay。浏览器麦克风通常要求 HTTPS 安全上下文(`localhost` 例外)。
|
|
117
117
|
|
|
118
|
+
### 使用本机 Sotto 进行语音输入(无需另一份 STT 模型)
|
|
119
|
+
|
|
120
|
+
先在**与 pi2web Backend 同一台机器**启动支持 `/api/capabilities` 和 `/api/transcribe` 的 Sotto Web 版本,再在 pi2web 网页「配置 → Sotto 语音输入」勾选启用并保存。默认 endpoint 为 `http://127.0.0.1:8766`,指的是 **Backend 所在机器**,不是访问网页的手机或电脑;服务端只允许配置 IPv4 回环 HTTP 地址,不允许指向公网。启用时会探测 endpoint,不可用则不会保存开启状态。设置保存在 pi2web 的数据目录,所有使用该 Backend 的网页共享;默认关闭,不配语音识别时麦克风按钮保持隐藏。
|
|
121
|
+
|
|
122
|
+
浏览器点击麦克风后持续采集单声道 float32 PCM,经 pi2web 已认证的 Agent WebSocket 转发至本机 Sotto;Sotto 持续返回 **partial/final**,网页边说边更新输入框;再次点击停止并定稿,**不会自动发送给 Agent**。切到 Terminal 等其他视图时录音继续;连续 1 分钟没有新的识别文字会自动停止并保留已识别内容。Sotto 复用已加载的 ASR(首次请求才加载一次),使用独立本机 WS 端口(默认 `8768`,即 Web 端口 +2),不对外开放。浏览器需支持安全上下文、Web Audio、麦克风权限;`Alt+Shift+V` 可启停。Sotto 的 ASR 实时测试运行时不能并用实时输入,普通监听可并存但共享同一个识别器。原有 `--voice-*` 语音摘要和 STT 功能保持独立;不要同时配置另一套 STT 模型,除非确实需要第二个识别服务。
|
|
123
|
+
|
|
124
|
+
实时听写采用**点击开始、再次点击停止**:停顿只用于分句定稿,不会结束录音,也没有固定一分钟的录音上限。停止时先发送 Worklet 中尚未发送的尾部音频,再等待最终结果。切换 Agent 或归档录音所属 Agent 会取消录音,切到 Terminal 则继续;关闭 Sotto 或修改其服务地址会结束该 Backend 所有网页已经建立的实时语音连接,当前网页尚在启动的采集也会取消。停止已有录音不受“能否开始新录音”的条件限制;连接、麦克风或音频积压异常会停止并提示原因。代理仍限制单帧大小及发送积压,连续 30 秒没有收到音频帧会报错关闭连接(静音也会产生音频帧,因此不是“静音超时”)。启动中再次点击可取消;后台运行时请留意浏览器的麦克风指示并主动停止录音。
|
|
125
|
+
|
|
118
126
|
### Edge TTS(仅语音合成)
|
|
119
127
|
|
|
120
128
|
项目提供了一个基于 Python `edge-tts` 和 `aiohttp` 的轻量本地服务。它直接返回 MP3,不需要 Docker 或 FFmpeg:
|
|
@@ -188,7 +196,7 @@ python scripts/migrate_data_dir.py # 复制 ~/.pi/remote-pi -> ~/.pi
|
|
|
188
196
|
- `workspaces`:Workspace ID、名称、根目录和创建时间。
|
|
189
197
|
- `agents`:Agent 与 Workspace/Session 的映射、cwd、状态、创建/最后活动时间以及 Archive 状态。
|
|
190
198
|
- `sessions`:用于 Session 列表、排序和分页的轻量索引,包括名称、文件路径、消息数量、文件 mtime/size 和最后活动时间。
|
|
191
|
-
- `agents.template`:Agent
|
|
199
|
+
- `agents.template`:Agent 的 System Prompt 模板快照(JSON)。仅在第一条会话消息前可修改,保存成功后才生效;对话开始后固定,卸载或重启不会改变。
|
|
192
200
|
- `agent-templates/*.md`:可分享的 Agent 模板文件,见下文「System Prompt 与 Agent 模板」。
|
|
193
201
|
- `auth.json`:配对码的 SHA-256 hash、创建和轮换时间;认证数据仍单独保存,不保存配对码明文,文件权限为 `0600`。
|
|
194
202
|
|
|
@@ -215,8 +223,9 @@ Web 端能力:
|
|
|
215
223
|
|
|
216
224
|
- Workspace 添加、目录浏览、文本文件分页及二进制提示。
|
|
217
225
|
- Workspace 目录右键在交互式 Terminal 中打开;Terminal 与 Agent 显示在同一列表,支持刷新重连、清屏和关闭。
|
|
218
|
-
-
|
|
219
|
-
- Prompt、steer、follow-up 和流式对话。
|
|
226
|
+
- 新建 Agent 时始终创建新 Session;已有 Agent 可在刷新或重启后继续使用。通过 Session 文件恢复对话暂仅支持 API,不在新建弹窗中提供;Agent 列表支持停止与 abort,右键 Archive。
|
|
227
|
+
- Prompt、steer、follow-up 和流式对话。HTTP / WebSocket 发送命令在 Pi 校验并接收消息后即确认,不等待整个回合结束;执行进度继续通过 WebSocket 同步。
|
|
228
|
+
- 手机切后台或网络中断导致发送确认丢失时,显示「送达状态未知」并保留原文供核对,不自动重发或放回输入框。提示在同一页面内切换会话、重连后仍保留,可手动关闭;刷新页面不保留。明确被服务端拒绝的消息仍恢复为草稿。
|
|
220
229
|
- 对话区只保留用户输入、Agent 输出、Thinking、Extension 对话框和错误;Tool call、Session bash 输出、Retry、Summarization retry 等过程事件自动折叠成一张"活动"卡片,卡片标题实时显示最新一项的名称和时间,展开后逐条查看,再点击单条查看内容。
|
|
221
230
|
- 同一次 Tool call 的 start / update / end 合并为一张卡片(参数 + 输出 + 结果),结束后显示 Pi 记录的执行耗时。
|
|
222
231
|
- Tool 自己发起的调用(codemode 脚本、MCP 批量调用)带 `parentToolCallId`,会嵌套显示在发起它的那张卡片里。
|
|
@@ -354,6 +363,7 @@ Web 看板在 `/collab.html`(首页顶部"协作"入口):会话列表与
|
|
|
354
363
|
- `GET /api/v1/agents/:id/mcp` 返回该 Agent cwd 下的 MCP 服务器配置(全局 `mcp.json` 与受信任项目的 `.pi/mcp.json`),`POST` 用 `{name, enabled}` 启用或停用。只有项目被 Pi 显式信任时才会写入项目级覆盖,否则写全局文件。修改在 Agent 下次加载时生效。
|
|
355
364
|
- `GET /api/v1/agents/:id/commands` 返回该 Agent cwd 下可用的 prompt template 和 skill(`{name, description, argumentHint, source}`)。`prompt` 接口本身会展开 `/命令`、skill 和 prompt template。
|
|
356
365
|
- `POST /api/v1/agents/:id/bash` 在 Agent Session 内执行命令(`{command, excludeFromContext}`),输出通过 `bash_execution_update` 事件流式推送并记录到 Session;`POST /api/v1/agents/:id/bash-abort` 取消。
|
|
366
|
+
- `POST /api/v1/agents/:id/pause` 安全暂停:对话不再前进——正在执行的工具跑完、结果写入 Session 后,不再产生新的模型回复或工具调用(Agent 循环的下一轮,以及自动重试、溢出压缩后的重试、排队消息、扩展 `agent_before_settle` 要求的续跑都会被拦下;正在等待的重试退避会被立即取消)。Session 以工具结果(或用户消息)结尾,重启后也不丢失,之后发送新消息即从这里继续;暂停时排队的消息留在队列里,随下一次 prompt 送达。不拦截不推进对话的整理性模型调用(压缩摘要、提示缓存保温;保温仅在 `cacheWarming: idle` 时于暂停后继续,最长 30 分钟)。进度通过 `safe_pause` 事件推送:`requested`、`reached`(已拦下一次模型请求,正在收尾)、`paused`、`cancelled`、`finished`(未到暂停点运行就自行结束);活动状态带 `pauseRequested {since, reached?}`。被暂停运行的 `agent_settled` 带 `paused: true`,邮件通知按“已中断”处理、语音不播报。`DELETE` 同一路径撤回暂停:已到暂停点、运行仍在收尾时(`reached` 之后、`paused` 之前)返回 `PAUSE_ALREADY_REACHED`;没有待生效的暂停时(包括运行已结束)为无操作并返回成功。撤回是否赶上以 `safe_pause` 事件(`cancelled` 或 `paused`)为准。
|
|
357
367
|
- WebSocket `/api/v1/ws`(协议 v2,不做事件回放):`subscribe_all` 先下发每个 Agent 的当前状态,再推送所有 Agent 的概览事件(状态、活动、队列、对话框、通知);`focus` 选定正在查看的 Agent,只为它推送流式文本、时间线条目变化和用户 Shell 输出;`watch` 订阅已展开的运行中工具的输出;`subscribe_collab` 推送协作事件。
|
|
358
368
|
- 会话内容通过 HTTP 时间线读取:`GET /api/v1/agents/:id/timeline?limit&before` 返回条目摘要(正文较小或需要阅读的条目内联),`GET /api/v1/agents/:id/sessions/:sessionId/items/:itemId` 按需返回完整正文;已完成条目永不改变,响应带 `immutable` 缓存头,浏览器可永久缓存。
|
|
359
369
|
|
|
@@ -376,7 +386,7 @@ mode: append # append:追加在 Pi Prompt 之后;replace:替换 Pi
|
|
|
376
386
|
You are a meticulous code reviewer. ...
|
|
377
387
|
```
|
|
378
388
|
|
|
379
|
-
新建 Agent 时可选择模板(默认是 Pi 自己的 Prompt
|
|
389
|
+
新建 Agent 时可选择模板(默认是 Pi 自己的 Prompt)。模板内容复制为 Agent 自己的快照;**第一条会话消息前可以修改,开始对话后不能更改**。模板替换与会话写入共用准入检查,写入期间不能替换,替换期间不能写入。修改操作先保存到数据库再启用,保存失败则保留原模板;Stop 或归档会使未完成的替换失效,迟到的候选会话会被释放,不会重新启用 Agent。后端重启或 Agent 重新加载仍使用最后保存的快照,不会退回创建时的选择。修改或删除模板文件不影响已有 Agent,Fork 出的 Agent 沿用同一模板。两种模式下 Pi 仍会加上 AGENTS.md 等项目上下文、skills 和 cwd;`replace` 会去掉 Pi 内置的工具说明、规则和文档段落,`APPEND_SYSTEM.md` 仍然生效。Agent 工具栏的「Prompt」按钮显示该 Agent 当前生效的完整 System Prompt。
|
|
380
390
|
|
|
381
391
|
运行时使用真实 `@earendil-works/pi-coding-agent` SDK,并复用 Pi CLI 的 `~/.pi/agent` 模型、认证和设置。`MockBackend` 只用于自动化测试。
|
|
382
392
|
|
package/dist/activity.js
CHANGED
|
@@ -22,6 +22,7 @@ const num = (value) => (typeof value === 'number' && Number.isFinite(value) ? va
|
|
|
22
22
|
export class ActivityTracker {
|
|
23
23
|
now;
|
|
24
24
|
state;
|
|
25
|
+
lastMetricsPublish = 0;
|
|
25
26
|
constructor(now = Date.now) {
|
|
26
27
|
this.now = now;
|
|
27
28
|
this.state = { run: null, phase: { kind: 'idle', since: now() }, bash: [], dialogs: [] };
|
|
@@ -32,7 +33,7 @@ export class ActivityTracker {
|
|
|
32
33
|
/** Forget everything, e.g. when the backend is unloaded. Returns whether anything changed. */
|
|
33
34
|
reset() {
|
|
34
35
|
const s = this.state;
|
|
35
|
-
if (!s.run && s.phase.kind === 'idle' && !s.bash.length && !s.dialogs.length)
|
|
36
|
+
if (!s.run && s.phase.kind === 'idle' && !s.bash.length && !s.dialogs.length && !s.pauseRequested)
|
|
36
37
|
return false;
|
|
37
38
|
this.state = { run: null, phase: { kind: 'idle', since: this.now() }, bash: [], dialogs: [] };
|
|
38
39
|
return true;
|
|
@@ -51,26 +52,52 @@ export class ActivityTracker {
|
|
|
51
52
|
const startedRun = !s.run;
|
|
52
53
|
if (startedRun)
|
|
53
54
|
s.run = { startedAt: this.now() };
|
|
55
|
+
if (startedRun)
|
|
56
|
+
s.metrics = { turnStartedAt: this.now(), chars: 0, estimatedTokens: 0 };
|
|
54
57
|
return this.setPhase({ kind: 'waiting_llm' }) || startedRun;
|
|
55
58
|
}
|
|
56
59
|
case 'turn_start':
|
|
57
|
-
|
|
60
|
+
s.metrics = { turnStartedAt: this.now(), chars: 0, estimatedTokens: 0 };
|
|
61
|
+
this.lastMetricsPublish = 0;
|
|
62
|
+
this.setPhase({ kind: 'waiting_llm' });
|
|
63
|
+
// Even when the last tool already moved us to waiting_llm, clients need the cleared metrics.
|
|
64
|
+
return true;
|
|
58
65
|
case 'message_update': {
|
|
59
66
|
const update = e.assistantMessageEvent, type = str(update?.type);
|
|
67
|
+
const at = this.now();
|
|
68
|
+
const firstResponse = !!s.metrics &&
|
|
69
|
+
s.metrics.firstResponseAt === undefined &&
|
|
70
|
+
(type === 'thinking_delta' || type === 'text_delta' || type === 'toolcall_delta');
|
|
71
|
+
if (firstResponse)
|
|
72
|
+
s.metrics.firstResponseAt = at;
|
|
73
|
+
let changed, lateName = false;
|
|
60
74
|
if (type === 'thinking_start' || type === 'thinking_delta')
|
|
61
|
-
|
|
62
|
-
if (type === 'text_start' || type === 'text_delta')
|
|
63
|
-
|
|
64
|
-
if (type === 'toolcall_start' || type === 'toolcall_delta') {
|
|
75
|
+
changed = this.setPhase({ kind: 'thinking' });
|
|
76
|
+
else if (type === 'text_start' || type === 'text_delta')
|
|
77
|
+
changed = this.setPhase({ kind: 'generating' });
|
|
78
|
+
else if (type === 'toolcall_start' || type === 'toolcall_delta') {
|
|
65
79
|
const toolName = str(update.partial?.content?.[update.contentIndex]?.name);
|
|
66
80
|
// The name may only arrive with a later delta: fill it in without restarting the timer.
|
|
67
81
|
if (s.phase.kind === 'preparing_tool' && !s.phase.toolName && toolName) {
|
|
68
82
|
s.phase.toolName = toolName;
|
|
69
|
-
|
|
83
|
+
changed = lateName = true;
|
|
70
84
|
}
|
|
71
|
-
|
|
85
|
+
else
|
|
86
|
+
changed = this.setPhase({ kind: 'preparing_tool', ...(toolName ? { toolName } : {}) });
|
|
72
87
|
}
|
|
73
|
-
|
|
88
|
+
else
|
|
89
|
+
return false;
|
|
90
|
+
// Throughput is per stream: each content block, e.g. each of two consecutive `write` calls,
|
|
91
|
+
// and each phase, so thinking does not dilute the text rate.
|
|
92
|
+
// A late tool name only labels the stream already running; it does not start a new one.
|
|
93
|
+
const cleared = ((changed && !lateName) || type.endsWith('_start')) && this.clearStream();
|
|
94
|
+
const counted = this.countDelta(update);
|
|
95
|
+
// Publish a stream's first count at once; later ones at most once a second.
|
|
96
|
+
if (counted && (counted === 'first' || at - this.lastMetricsPublish >= 1000)) {
|
|
97
|
+
this.lastMetricsPublish = at;
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
return changed || cleared || firstResponse;
|
|
74
101
|
}
|
|
75
102
|
case 'tool_execution_start': {
|
|
76
103
|
// Tools called by other tools run inside their caller's time; show only top-level calls.
|
|
@@ -143,6 +170,23 @@ export class ActivityTracker {
|
|
|
143
170
|
s.dialogs.push({ requestId, title: str(e.title) ?? str(e.message), since: this.now() });
|
|
144
171
|
return true;
|
|
145
172
|
}
|
|
173
|
+
case 'safe_pause':
|
|
174
|
+
if (e.state === 'requested') {
|
|
175
|
+
if (s.pauseRequested)
|
|
176
|
+
return false;
|
|
177
|
+
s.pauseRequested = { since: this.now() };
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
if (e.state === 'reached') {
|
|
181
|
+
if (s.pauseRequested?.reached)
|
|
182
|
+
return false;
|
|
183
|
+
s.pauseRequested = { since: s.pauseRequested?.since ?? this.now(), reached: true };
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
if (!s.pauseRequested)
|
|
187
|
+
return false;
|
|
188
|
+
delete s.pauseRequested;
|
|
189
|
+
return true;
|
|
146
190
|
case 'extension_ui_response': {
|
|
147
191
|
const before = s.dialogs.length;
|
|
148
192
|
s.dialogs = s.dialogs.filter(d => d.requestId !== e.requestId);
|
|
@@ -152,9 +196,39 @@ export class ActivityTracker {
|
|
|
152
196
|
return false;
|
|
153
197
|
}
|
|
154
198
|
}
|
|
199
|
+
/** Adds a thinking, text or tool call delta to the current stream's estimate. Returns whether it counted. */
|
|
200
|
+
/** Restarts the current stream's counters. Returns whether there was anything to clear. */
|
|
201
|
+
clearStream() {
|
|
202
|
+
const metrics = this.state.metrics;
|
|
203
|
+
if (!metrics || (metrics.streamStartedAt === undefined && !metrics.chars))
|
|
204
|
+
return false;
|
|
205
|
+
delete metrics.streamStartedAt;
|
|
206
|
+
metrics.chars = 0;
|
|
207
|
+
metrics.estimatedTokens = 0;
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Adds a thinking, text or tool call delta to the current stream's estimate. Returns `'first'` for
|
|
212
|
+
* the stream's first counted delta, `true` for later ones and `false` if nothing was counted.
|
|
213
|
+
*/
|
|
214
|
+
countDelta(update) {
|
|
215
|
+
const metrics = this.state.metrics, delta = update?.delta;
|
|
216
|
+
if (!metrics || !String(update?.type).endsWith('_delta') || typeof delta !== 'string' || !delta)
|
|
217
|
+
return false;
|
|
218
|
+
const first = metrics.streamStartedAt === undefined;
|
|
219
|
+
if (first)
|
|
220
|
+
metrics.streamStartedAt = this.now();
|
|
221
|
+
// Approximate Latin/code text at 4 chars/token, CJK at 1.5 chars/token.
|
|
222
|
+
const cjk = delta.match(/[\u3400-\u9fff\u3040-\u30ff\uac00-\ud7af]/gu)?.length ?? 0;
|
|
223
|
+
const chars = [...delta].length;
|
|
224
|
+
metrics.chars += chars;
|
|
225
|
+
metrics.estimatedTokens += (chars - cjk) / 4 + cjk / 1.5;
|
|
226
|
+
return first ? 'first' : true;
|
|
227
|
+
}
|
|
155
228
|
endRun(settled = false) {
|
|
156
229
|
const hadRun = !!this.state.run;
|
|
157
230
|
this.state.run = null;
|
|
231
|
+
delete this.state.metrics;
|
|
158
232
|
// Compaction may continue after the run has ended; agent_settled closes it.
|
|
159
233
|
if (this.state.phase.kind === 'compacting' && !settled)
|
|
160
234
|
return hadRun;
|
package/dist/agents.js
CHANGED
|
@@ -5,6 +5,7 @@ import { createSdkBackend } from './sdk-backend.js';
|
|
|
5
5
|
import { ActivityTracker } from './activity.js';
|
|
6
6
|
import { AgentFeed } from './agent-feed.js';
|
|
7
7
|
import { buildTimeline, itemBody, pageTimeline } from './timeline.js';
|
|
8
|
+
import { sessionPerformance } from './session-performance.js';
|
|
8
9
|
/**
|
|
9
10
|
* Reduce a Pi SDK event to the fields server-side consumers and clients use, or drop it.
|
|
10
11
|
* Heavy SDK events are whitelisted field by field (Pi attaches whole messages to many of
|
|
@@ -56,6 +57,7 @@ export class MockBackend {
|
|
|
56
57
|
entries = [];
|
|
57
58
|
timer;
|
|
58
59
|
queue = { steering: [], followUp: [] };
|
|
60
|
+
pauseRequested = false;
|
|
59
61
|
constructor(agentId, cwd, sessionId = randomUUID(), options = {}) {
|
|
60
62
|
this.options = options;
|
|
61
63
|
this.state = { agentId, sessionId, cwd, status: 'idle' };
|
|
@@ -81,11 +83,12 @@ export class MockBackend {
|
|
|
81
83
|
this.timer = setTimeout(r, 0);
|
|
82
84
|
});
|
|
83
85
|
}
|
|
84
|
-
async prompt(message) {
|
|
86
|
+
async prompt(message, onAccepted) {
|
|
85
87
|
if (this.state.status === 'streaming')
|
|
86
88
|
throw Object.assign(new Error('Agent is busy'), { code: 'AGENT_BUSY' });
|
|
87
89
|
this.state.status = 'streaming';
|
|
88
90
|
this.emit({ type: 'agent_start', message });
|
|
91
|
+
onAccepted?.();
|
|
89
92
|
await this.runTurn();
|
|
90
93
|
const user = { role: 'user', content: message };
|
|
91
94
|
this.emit({ type: 'message_end', message: user });
|
|
@@ -97,6 +100,25 @@ export class MockBackend {
|
|
|
97
100
|
this.append(assistant);
|
|
98
101
|
this.state.status = 'idle';
|
|
99
102
|
this.emit({ type: 'agent_end' });
|
|
103
|
+
// A mock run is a single final answer, so a pending pause never finds a safe point.
|
|
104
|
+
if (this.pauseRequested) {
|
|
105
|
+
this.pauseRequested = false;
|
|
106
|
+
this.emit({ type: 'safe_pause', state: 'finished' });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async requestPause() {
|
|
110
|
+
if (this.state.status !== 'streaming')
|
|
111
|
+
throw Object.assign(new Error('Agent is not running'), { code: 'AGENT_NOT_RUNNING' });
|
|
112
|
+
if (this.pauseRequested)
|
|
113
|
+
return;
|
|
114
|
+
this.pauseRequested = true;
|
|
115
|
+
this.emit({ type: 'safe_pause', state: 'requested' });
|
|
116
|
+
}
|
|
117
|
+
async cancelPause() {
|
|
118
|
+
if (!this.pauseRequested)
|
|
119
|
+
return;
|
|
120
|
+
this.pauseRequested = false;
|
|
121
|
+
this.emit({ type: 'safe_pause', state: 'cancelled' });
|
|
100
122
|
}
|
|
101
123
|
async steer(message) {
|
|
102
124
|
const queued = this.state.status === 'streaming';
|
|
@@ -125,6 +147,17 @@ export class MockBackend {
|
|
|
125
147
|
this.emitQueue();
|
|
126
148
|
return cleared;
|
|
127
149
|
}
|
|
150
|
+
async moveQueue(from, index, expected) {
|
|
151
|
+
if (this.state.status !== 'streaming' || this.queue[from][index] !== expected)
|
|
152
|
+
throw Object.assign(new Error('Queued message has changed or already been consumed'), {
|
|
153
|
+
code: 'QUEUE_CHANGED'
|
|
154
|
+
});
|
|
155
|
+
const to = from === 'steering' ? 'followUp' : 'steering';
|
|
156
|
+
this.queue[from].splice(index, 1);
|
|
157
|
+
this.queue[to].push(expected);
|
|
158
|
+
this.emitQueue();
|
|
159
|
+
return this.getQueue();
|
|
160
|
+
}
|
|
128
161
|
emitQueue() {
|
|
129
162
|
this.emit({ type: 'queue_update', steering: [...this.queue.steering], followUp: [...this.queue.followUp] });
|
|
130
163
|
}
|
|
@@ -134,6 +167,10 @@ export class MockBackend {
|
|
|
134
167
|
clearTimeout(this.timer);
|
|
135
168
|
this.state.status = 'idle';
|
|
136
169
|
this.emit({ type: 'agent_end', aborted: true });
|
|
170
|
+
if (this.pauseRequested) {
|
|
171
|
+
this.pauseRequested = false;
|
|
172
|
+
this.emit({ type: 'safe_pause', state: 'finished' });
|
|
173
|
+
}
|
|
137
174
|
}
|
|
138
175
|
}
|
|
139
176
|
async getState() {
|
|
@@ -340,6 +377,7 @@ export class AgentManager {
|
|
|
340
377
|
const entry = {
|
|
341
378
|
record,
|
|
342
379
|
backend,
|
|
380
|
+
writes: 0,
|
|
343
381
|
activity: new ActivityTracker(),
|
|
344
382
|
dialogRequests: new Map(),
|
|
345
383
|
queue: emptyQueue()
|
|
@@ -369,6 +407,7 @@ export class AgentManager {
|
|
|
369
407
|
};
|
|
370
408
|
this.agents.set(saved.agentId, {
|
|
371
409
|
record,
|
|
410
|
+
writes: 0,
|
|
372
411
|
activity: new ActivityTracker(),
|
|
373
412
|
dialogRequests: new Map(),
|
|
374
413
|
queue: emptyQueue()
|
|
@@ -391,8 +430,31 @@ export class AgentManager {
|
|
|
391
430
|
isLoaded(id) {
|
|
392
431
|
return !!this.get(id).backend;
|
|
393
432
|
}
|
|
433
|
+
assertCurrent(entry, backend) {
|
|
434
|
+
if (entry.unloading ||
|
|
435
|
+
this.agents.get(entry.record.agentId) !== entry ||
|
|
436
|
+
(backend && entry.backend !== backend))
|
|
437
|
+
throw Object.assign(new Error('Agent session changed; retry the command'), { code: 'SESSION_BUSY' });
|
|
438
|
+
}
|
|
439
|
+
/** All session mutations share admission. Replacement is exclusive; ordinary writes remain concurrent. */
|
|
440
|
+
async writeSession(id, action) {
|
|
441
|
+
const entry = this.get(id);
|
|
442
|
+
this.assertCurrent(entry);
|
|
443
|
+
if (entry.templateChanging)
|
|
444
|
+
throw Object.assign(new Error('System Prompt is being changed'), { code: 'SESSION_BUSY' });
|
|
445
|
+
entry.writes++;
|
|
446
|
+
try {
|
|
447
|
+
const backend = await this.ensureLoaded(id);
|
|
448
|
+
this.assertCurrent(entry, backend);
|
|
449
|
+
return await action(backend, entry);
|
|
450
|
+
}
|
|
451
|
+
finally {
|
|
452
|
+
entry.writes--;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
394
455
|
async ensureLoaded(id) {
|
|
395
456
|
const entry = this.get(id);
|
|
457
|
+
this.assertCurrent(entry);
|
|
396
458
|
if (entry.backend)
|
|
397
459
|
return entry.backend;
|
|
398
460
|
if (entry.record.status === 'stopped')
|
|
@@ -403,7 +465,15 @@ export class AgentManager {
|
|
|
403
465
|
entry.loading = (async () => {
|
|
404
466
|
try {
|
|
405
467
|
const backend = await this.factory(id, entry.record.cwd, entry.record.sessionFile, entry.record.profile, entry.record.template ? { template: entry.record.template } : {});
|
|
406
|
-
|
|
468
|
+
let state;
|
|
469
|
+
try {
|
|
470
|
+
state = await backend.getState();
|
|
471
|
+
this.assertCurrent(entry);
|
|
472
|
+
}
|
|
473
|
+
catch (error) {
|
|
474
|
+
await backend.dispose();
|
|
475
|
+
throw error;
|
|
476
|
+
}
|
|
407
477
|
entry.record = {
|
|
408
478
|
...entry.record,
|
|
409
479
|
...state,
|
|
@@ -416,7 +486,8 @@ export class AgentManager {
|
|
|
416
486
|
return backend;
|
|
417
487
|
}
|
|
418
488
|
catch (error) {
|
|
419
|
-
entry.
|
|
489
|
+
if (!entry.unloading)
|
|
490
|
+
entry.record.status = 'error';
|
|
420
491
|
throw error;
|
|
421
492
|
}
|
|
422
493
|
finally {
|
|
@@ -425,19 +496,55 @@ export class AgentManager {
|
|
|
425
496
|
})();
|
|
426
497
|
return entry.loading;
|
|
427
498
|
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
499
|
+
/** Transport acknowledgement: an accepted prompt keeps running under the normal write admission. */
|
|
500
|
+
submitCommand(id, kind, message = '') {
|
|
501
|
+
return new Promise((resolve, reject) => {
|
|
502
|
+
let accepted = false;
|
|
503
|
+
void this.command(id, kind, message, () => {
|
|
504
|
+
accepted = true;
|
|
505
|
+
resolve();
|
|
506
|
+
}).then(resolve, error => {
|
|
507
|
+
if (!accepted)
|
|
508
|
+
return reject(error);
|
|
509
|
+
// The sender already has its acknowledgement. Report execution errors through the existing
|
|
510
|
+
// event stream rather than turning a delivered prompt into a failed send.
|
|
511
|
+
const entry = this.agents.get(id);
|
|
512
|
+
if (entry)
|
|
513
|
+
this.publish(entry, {
|
|
514
|
+
type: 'extension_ui_notify',
|
|
515
|
+
notificationType: 'error',
|
|
516
|
+
message: error instanceof Error ? error.message : String(error)
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
async command(id, kind, message = '', onAccepted) {
|
|
435
522
|
if (kind === 'abort') {
|
|
523
|
+
const entry = this.get(id);
|
|
524
|
+
if (!entry.backend)
|
|
525
|
+
return;
|
|
436
526
|
if (entry.activity.stopping())
|
|
437
527
|
this.publishActivity(entry);
|
|
438
|
-
return backend.abort();
|
|
528
|
+
return entry.backend.abort();
|
|
439
529
|
}
|
|
440
|
-
return
|
|
530
|
+
return this.writeSession(id, async (backend, entry) => {
|
|
531
|
+
const state = await backend.getState();
|
|
532
|
+
this.assertCurrent(entry, backend);
|
|
533
|
+
// Read and reserve without yielding: simultaneous idle inputs start exactly one turn.
|
|
534
|
+
const busy = entry.promptPending || entry.streaming || state.status === 'streaming';
|
|
535
|
+
const effective = busy ? (kind === 'prompt' ? 'follow-up' : kind) : 'prompt';
|
|
536
|
+
if (effective === 'prompt')
|
|
537
|
+
entry.promptPending = true;
|
|
538
|
+
try {
|
|
539
|
+
return effective === 'prompt'
|
|
540
|
+
? await backend.prompt(message, onAccepted)
|
|
541
|
+
: await backend[effective === 'follow-up' ? 'followUp' : 'steer'](message);
|
|
542
|
+
}
|
|
543
|
+
finally {
|
|
544
|
+
if (effective === 'prompt' && entry.backend === backend)
|
|
545
|
+
entry.promptPending = false;
|
|
546
|
+
}
|
|
547
|
+
});
|
|
441
548
|
}
|
|
442
549
|
/** Queued steering and follow-up messages waiting for the running turn. */
|
|
443
550
|
// Both read through to the backend and leave the cache to `queue_update`, which stays correct when a
|
|
@@ -451,7 +558,10 @@ export class AgentManager {
|
|
|
451
558
|
/** Drop queued messages and return them so the caller can restore them to its composer. */
|
|
452
559
|
async clearQueue(id) {
|
|
453
560
|
const entry = this.get(id);
|
|
454
|
-
return entry.backend ?
|
|
561
|
+
return entry.backend ? this.writeSession(id, backend => backend.clearQueue()) : emptyQueue();
|
|
562
|
+
}
|
|
563
|
+
async moveQueue(id, from, index, expected) {
|
|
564
|
+
return this.writeSession(id, backend => backend.moveQueue(from, index, expected));
|
|
455
565
|
}
|
|
456
566
|
snapshotState(id) {
|
|
457
567
|
const a = this.get(id);
|
|
@@ -490,20 +600,36 @@ export class AgentManager {
|
|
|
490
600
|
this.globalListeners.add(listener);
|
|
491
601
|
return () => this.globalListeners.delete(listener);
|
|
492
602
|
}
|
|
493
|
-
|
|
494
|
-
const
|
|
495
|
-
if (
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
603
|
+
dispose(id) {
|
|
604
|
+
const entry = this.get(id);
|
|
605
|
+
if (entry.unloading)
|
|
606
|
+
return entry.unloading;
|
|
607
|
+
// Reserve Stop synchronously. In-flight loads/replacements must validate before attaching.
|
|
608
|
+
entry.unloading = Promise.resolve()
|
|
609
|
+
.then(async () => {
|
|
610
|
+
if (entry.loading)
|
|
611
|
+
await entry.loading.catch(() => { });
|
|
612
|
+
const backend = entry.backend;
|
|
613
|
+
entry.backend = undefined;
|
|
614
|
+
entry.feed?.close();
|
|
615
|
+
entry.feed = undefined;
|
|
616
|
+
try {
|
|
617
|
+
await backend?.dispose();
|
|
618
|
+
}
|
|
619
|
+
finally {
|
|
620
|
+
entry.record.status = 'unloaded';
|
|
621
|
+
entry.streaming = false;
|
|
622
|
+
entry.promptPending = false;
|
|
623
|
+
entry.dialogRequests.clear();
|
|
624
|
+
entry.activity.reset();
|
|
625
|
+
entry.queue = emptyQueue();
|
|
626
|
+
this.publish(entry, { type: 'agent_unloaded' });
|
|
627
|
+
}
|
|
628
|
+
})
|
|
629
|
+
.finally(() => {
|
|
630
|
+
entry.unloading = undefined;
|
|
631
|
+
});
|
|
632
|
+
return entry.unloading;
|
|
507
633
|
}
|
|
508
634
|
async remove(id) {
|
|
509
635
|
await this.dispose(id);
|
|
@@ -530,6 +656,11 @@ export class AgentManager {
|
|
|
530
656
|
throw Object.assign(new Error('Timeline item not found'), { code: 'TIMELINE_ITEM_NOT_FOUND' });
|
|
531
657
|
return { sessionId: source.sessionId, ...page };
|
|
532
658
|
}
|
|
659
|
+
/** All timing records of the current branch; older sessions only have partial tool timings. */
|
|
660
|
+
async performance(id) {
|
|
661
|
+
const source = (await this.ensureLoaded(id)).timeline();
|
|
662
|
+
return { sessionId: source.sessionId, ...sessionPerformance(source.entries()) };
|
|
663
|
+
}
|
|
533
664
|
/** The full body of one timeline item of the given session. It never changes. */
|
|
534
665
|
async item(id, sessionId, itemId) {
|
|
535
666
|
const source = (await this.ensureLoaded(id)).timeline(), body = source.sessionId === sessionId ? itemBody(buildTimeline(source.entries()), itemId) : undefined;
|
|
@@ -541,6 +672,57 @@ export class AgentManager {
|
|
|
541
672
|
const info = await (await this.ensureLoaded(id)).getSystemPrompt();
|
|
542
673
|
return { ...info, template: this.get(id).record.template ?? null };
|
|
543
674
|
}
|
|
675
|
+
/** Prepare → persist → activate under the template lock. A failed save leaves the old session intact. */
|
|
676
|
+
async setTemplate(id, template, persist) {
|
|
677
|
+
const entry = this.get(id);
|
|
678
|
+
this.assertCurrent(entry);
|
|
679
|
+
if (entry.record.profile !== 'default' ||
|
|
680
|
+
entry.record.status === 'stopped' ||
|
|
681
|
+
entry.streaming ||
|
|
682
|
+
entry.writes)
|
|
683
|
+
throw Object.assign(new Error('System Prompt can only change before the first turn'), {
|
|
684
|
+
code: 'SESSION_STARTED'
|
|
685
|
+
});
|
|
686
|
+
if (entry.templateChanging)
|
|
687
|
+
throw Object.assign(new Error('System Prompt is being changed'), { code: 'SESSION_BUSY' });
|
|
688
|
+
entry.templateChanging = true;
|
|
689
|
+
try {
|
|
690
|
+
const old = await this.ensureLoaded(id);
|
|
691
|
+
this.assertCurrent(entry, old);
|
|
692
|
+
const session = await old.getSession();
|
|
693
|
+
if (session.entries.some(x => x.type === 'message'))
|
|
694
|
+
throw Object.assign(new Error('This session already has messages'), { code: 'SESSION_STARTED' });
|
|
695
|
+
const capabilities = await old.getCapabilities();
|
|
696
|
+
this.assertCurrent(entry, old);
|
|
697
|
+
const replacement = await this.factory(id, entry.record.cwd, entry.record.sessionFile, entry.record.profile, template ? { template } : {});
|
|
698
|
+
try {
|
|
699
|
+
const model = capabilities.model;
|
|
700
|
+
if (model?.provider && model?.id)
|
|
701
|
+
await replacement.setModel(model.provider, model.id);
|
|
702
|
+
if (typeof capabilities.thinkingLevel === 'string')
|
|
703
|
+
await replacement.setThinkingLevel(capabilities.thinkingLevel);
|
|
704
|
+
if (entry.record.sessionName)
|
|
705
|
+
await replacement.setSessionName(entry.record.sessionName);
|
|
706
|
+
const state = await replacement.getState();
|
|
707
|
+
this.assertCurrent(entry, old);
|
|
708
|
+
const record = { ...entry.record, ...state, template };
|
|
709
|
+
// Persistence is synchronous: no command can observe a choice that has not been committed.
|
|
710
|
+
persist(record);
|
|
711
|
+
entry.record = record;
|
|
712
|
+
this.connect(entry, replacement);
|
|
713
|
+
this.publish(entry, { type: 'agent_loaded' });
|
|
714
|
+
await old.dispose().catch(() => { });
|
|
715
|
+
return record;
|
|
716
|
+
}
|
|
717
|
+
catch (error) {
|
|
718
|
+
await replacement.dispose();
|
|
719
|
+
throw error;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
finally {
|
|
723
|
+
entry.templateChanging = false;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
544
726
|
async capabilities(id) {
|
|
545
727
|
return (await this.ensureLoaded(id)).getCapabilities();
|
|
546
728
|
}
|
|
@@ -557,40 +739,59 @@ export class AgentManager {
|
|
|
557
739
|
return { ...info, sessionName: name };
|
|
558
740
|
}
|
|
559
741
|
async compact(id, instructions) {
|
|
560
|
-
return
|
|
742
|
+
return this.writeSession(id, backend => backend.compact(instructions));
|
|
561
743
|
}
|
|
562
744
|
async setModel(id, provider, modelId) {
|
|
563
|
-
return
|
|
745
|
+
return this.writeSession(id, backend => backend.setModel(provider, modelId));
|
|
564
746
|
}
|
|
565
747
|
async setThinkingLevel(id, level) {
|
|
566
|
-
return
|
|
748
|
+
return this.writeSession(id, backend => backend.setThinkingLevel(level));
|
|
567
749
|
}
|
|
568
750
|
async setSessionName(id, name) {
|
|
569
|
-
|
|
570
|
-
|
|
751
|
+
return this.writeSession(id, async (backend, entry) => {
|
|
752
|
+
await backend.setSessionName(name);
|
|
753
|
+
this.assertCurrent(entry, backend);
|
|
754
|
+
entry.record.sessionName = name.trim();
|
|
755
|
+
});
|
|
571
756
|
}
|
|
572
757
|
async navigate(id, entryId) {
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
758
|
+
return this.writeSession(id, async (backend, entry) => {
|
|
759
|
+
const result = await backend.navigate(entryId);
|
|
760
|
+
this.assertCurrent(entry, backend);
|
|
761
|
+
entry.feed?.sync(); // Moving the leaf back is no event; tell viewers to reload.
|
|
762
|
+
return result;
|
|
763
|
+
});
|
|
576
764
|
}
|
|
577
765
|
async fork(id, entryId) {
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
766
|
+
return this.writeSession(id, async (backend, source) => {
|
|
767
|
+
const result = await backend.fork(entryId);
|
|
768
|
+
this.assertCurrent(source, backend);
|
|
769
|
+
if (!result.sessionFile)
|
|
770
|
+
throw Object.assign(new Error('Session persistence is disabled'), { code: 'SESSION_NOT_PERSISTED' });
|
|
771
|
+
const ws = this.workspaces.get(source.record.workspaceId);
|
|
772
|
+
const agent = await this.create(source.record.workspaceId, pathRelative(ws.rootPath, source.record.cwd), result.sessionFile, source.record.profile, source.record.template);
|
|
773
|
+
return { agent, selectedText: result.selectedText };
|
|
774
|
+
});
|
|
584
775
|
}
|
|
585
776
|
async extensionResponse(id, requestId, value) {
|
|
586
|
-
return
|
|
777
|
+
return this.writeSession(id, backend => backend.extensionResponse(requestId, value));
|
|
587
778
|
}
|
|
588
779
|
async listCommands(id) {
|
|
589
780
|
return (await this.ensureLoaded(id)).listCommands();
|
|
590
781
|
}
|
|
591
782
|
/** Session-scoped bash. Output is streamed as `bash_execution_update` events and recorded in the Session. */
|
|
592
783
|
async runBash(id, command, excludeFromContext = false) {
|
|
593
|
-
return
|
|
784
|
+
return this.writeSession(id, backend => backend.runBash(command, excludeFromContext));
|
|
785
|
+
}
|
|
786
|
+
/** Stop the running turn at its next safe point: after its tools, before the next model request. */
|
|
787
|
+
async requestPause(id) {
|
|
788
|
+
const entry = this.get(id);
|
|
789
|
+
if (!entry.backend)
|
|
790
|
+
throw Object.assign(new Error('Agent is not running'), { code: 'AGENT_NOT_RUNNING' });
|
|
791
|
+
return entry.backend.requestPause();
|
|
792
|
+
}
|
|
793
|
+
async cancelPause(id) {
|
|
794
|
+
return this.get(id).backend?.cancelPause();
|
|
594
795
|
}
|
|
595
796
|
async abortBash(id) {
|
|
596
797
|
const entry = this.get(id);
|