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 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` 时,Web UI 才显示麦克风按钮;识别文字只插入输入框,不会自动发送。
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 创建时选定的 System Prompt 模板快照(JSON),之后不可修改。
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
- - 创建/恢复 Session、Agent 列表、停止与 abort;Agent 右键 Archive。
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)。模板内容在创建时复制到 Agent 记录中,**之后不能更改**:修改或删除模板文件不影响已有 Agent,Fork 出的 Agent 沿用同一模板。两种模式下 Pi 仍会加上 AGENTS.md 等项目上下文、skills 和 cwd;`replace` 会去掉 Pi 内置的工具说明、规则和文档段落,`APPEND_SYSTEM.md` 仍然生效。Agent 工具栏的「Prompt」按钮显示该 Agent 当前生效的完整 System 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
- return this.setPhase({ kind: 'waiting_llm' });
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
- return this.setPhase({ kind: 'thinking' });
62
- if (type === 'text_start' || type === 'text_delta')
63
- return this.setPhase({ kind: 'generating' });
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
- return true;
83
+ changed = lateName = true;
70
84
  }
71
- return this.setPhase({ kind: 'preparing_tool', ...(toolName ? { toolName } : {}) });
85
+ else
86
+ changed = this.setPhase({ kind: 'preparing_tool', ...(toolName ? { toolName } : {}) });
72
87
  }
73
- return false;
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
- const state = await backend.getState();
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.record.status = 'error';
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
- async command(id, kind, message = '') {
429
- const entry = this.get(id);
430
- if (kind === 'abort' && !entry.backend)
431
- return;
432
- if (entry.record.status === 'stopped')
433
- throw Object.assign(new Error('Agent is not running'), { code: 'AGENT_NOT_RUNNING' });
434
- const backend = await this.ensureLoaded(id);
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 backend[kind === 'follow-up' ? 'followUp' : kind](message);
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 ? entry.backend.clearQueue() : emptyQueue();
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
- async dispose(id) {
494
- const a = this.get(id);
495
- if (a.loading)
496
- await a.loading.catch(() => { });
497
- await a.backend?.dispose();
498
- a.backend = undefined;
499
- a.feed?.close();
500
- a.feed = undefined;
501
- a.record.status = 'unloaded';
502
- a.streaming = false;
503
- a.dialogRequests.clear();
504
- a.activity.reset(); // agent_unloaded below tells clients the activity is gone.
505
- a.queue = emptyQueue();
506
- this.publish(a, { type: 'agent_unloaded' });
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 (await this.ensureLoaded(id)).compact(instructions);
742
+ return this.writeSession(id, backend => backend.compact(instructions));
561
743
  }
562
744
  async setModel(id, provider, modelId) {
563
- return (await this.ensureLoaded(id)).setModel(provider, modelId);
745
+ return this.writeSession(id, backend => backend.setModel(provider, modelId));
564
746
  }
565
747
  async setThinkingLevel(id, level) {
566
- return (await this.ensureLoaded(id)).setThinkingLevel(level);
748
+ return this.writeSession(id, backend => backend.setThinkingLevel(level));
567
749
  }
568
750
  async setSessionName(id, name) {
569
- await (await this.ensureLoaded(id)).setSessionName(name);
570
- this.get(id).record.sessionName = name.trim();
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
- const result = await (await this.ensureLoaded(id)).navigate(entryId);
574
- this.get(id).feed?.sync(); // Moving the leaf back is no event; tell viewers to reload.
575
- return result;
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
- const source = this.get(id), result = await (await this.ensureLoaded(id)).fork(entryId);
579
- if (!result.sessionFile)
580
- throw Object.assign(new Error('Session persistence is disabled'), { code: 'SESSION_NOT_PERSISTED' });
581
- const ws = this.workspaces.get(source.record.workspaceId);
582
- const agent = await this.create(source.record.workspaceId, pathRelative(ws.rootPath, source.record.cwd), result.sessionFile, source.record.profile, source.record.template);
583
- return { agent, selectedText: result.selectedText };
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 (await this.ensureLoaded(id)).extensionResponse(requestId, value);
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 (await this.ensureLoaded(id)).runBash(command, excludeFromContext);
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);