pi2web 0.2.6 → 0.2.8

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
 
@@ -376,7 +384,7 @@ mode: append # append:追加在 Pi Prompt 之后;replace:替换 Pi
376
384
  You are a meticulous code reviewer. ...
377
385
  ```
378
386
 
379
- 新建 Agent 时可选择模板(默认是 Pi 自己的 Prompt)。模板内容在创建时复制到 Agent 记录中,**之后不能更改**:修改或删除模板文件不影响已有 Agent,Fork 出的 Agent 沿用同一模板。两种模式下 Pi 仍会加上 AGENTS.md 等项目上下文、skills 和 cwd;`replace` 会去掉 Pi 内置的工具说明、规则和文档段落,`APPEND_SYSTEM.md` 仍然生效。Agent 工具栏的「Prompt」按钮显示该 Agent 当前生效的完整 System Prompt。
387
+ 新建 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
388
 
381
389
  运行时使用真实 `@earendil-works/pi-coding-agent` SDK,并复用 Pi CLI 的 `~/.pi/agent` 模型、认证和设置。`MockBackend` 只用于自动化测试。
382
390
 
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
@@ -125,6 +126,17 @@ export class MockBackend {
125
126
  this.emitQueue();
126
127
  return cleared;
127
128
  }
129
+ async moveQueue(from, index, expected) {
130
+ if (this.state.status !== 'streaming' || this.queue[from][index] !== expected)
131
+ throw Object.assign(new Error('Queued message has changed or already been consumed'), {
132
+ code: 'QUEUE_CHANGED'
133
+ });
134
+ const to = from === 'steering' ? 'followUp' : 'steering';
135
+ this.queue[from].splice(index, 1);
136
+ this.queue[to].push(expected);
137
+ this.emitQueue();
138
+ return this.getQueue();
139
+ }
128
140
  emitQueue() {
129
141
  this.emit({ type: 'queue_update', steering: [...this.queue.steering], followUp: [...this.queue.followUp] });
130
142
  }
@@ -340,6 +352,7 @@ export class AgentManager {
340
352
  const entry = {
341
353
  record,
342
354
  backend,
355
+ writes: 0,
343
356
  activity: new ActivityTracker(),
344
357
  dialogRequests: new Map(),
345
358
  queue: emptyQueue()
@@ -369,6 +382,7 @@ export class AgentManager {
369
382
  };
370
383
  this.agents.set(saved.agentId, {
371
384
  record,
385
+ writes: 0,
372
386
  activity: new ActivityTracker(),
373
387
  dialogRequests: new Map(),
374
388
  queue: emptyQueue()
@@ -391,8 +405,31 @@ export class AgentManager {
391
405
  isLoaded(id) {
392
406
  return !!this.get(id).backend;
393
407
  }
408
+ assertCurrent(entry, backend) {
409
+ if (entry.unloading ||
410
+ this.agents.get(entry.record.agentId) !== entry ||
411
+ (backend && entry.backend !== backend))
412
+ throw Object.assign(new Error('Agent session changed; retry the command'), { code: 'SESSION_BUSY' });
413
+ }
414
+ /** All session mutations share admission. Replacement is exclusive; ordinary writes remain concurrent. */
415
+ async writeSession(id, action) {
416
+ const entry = this.get(id);
417
+ this.assertCurrent(entry);
418
+ if (entry.templateChanging)
419
+ throw Object.assign(new Error('System Prompt is being changed'), { code: 'SESSION_BUSY' });
420
+ entry.writes++;
421
+ try {
422
+ const backend = await this.ensureLoaded(id);
423
+ this.assertCurrent(entry, backend);
424
+ return await action(backend, entry);
425
+ }
426
+ finally {
427
+ entry.writes--;
428
+ }
429
+ }
394
430
  async ensureLoaded(id) {
395
431
  const entry = this.get(id);
432
+ this.assertCurrent(entry);
396
433
  if (entry.backend)
397
434
  return entry.backend;
398
435
  if (entry.record.status === 'stopped')
@@ -403,7 +440,15 @@ export class AgentManager {
403
440
  entry.loading = (async () => {
404
441
  try {
405
442
  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();
443
+ let state;
444
+ try {
445
+ state = await backend.getState();
446
+ this.assertCurrent(entry);
447
+ }
448
+ catch (error) {
449
+ await backend.dispose();
450
+ throw error;
451
+ }
407
452
  entry.record = {
408
453
  ...entry.record,
409
454
  ...state,
@@ -416,7 +461,8 @@ export class AgentManager {
416
461
  return backend;
417
462
  }
418
463
  catch (error) {
419
- entry.record.status = 'error';
464
+ if (!entry.unloading)
465
+ entry.record.status = 'error';
420
466
  throw error;
421
467
  }
422
468
  finally {
@@ -426,18 +472,30 @@ export class AgentManager {
426
472
  return entry.loading;
427
473
  }
428
474
  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);
435
475
  if (kind === 'abort') {
476
+ const entry = this.get(id);
477
+ if (!entry.backend)
478
+ return;
436
479
  if (entry.activity.stopping())
437
480
  this.publishActivity(entry);
438
- return backend.abort();
481
+ return entry.backend.abort();
439
482
  }
440
- return backend[kind === 'follow-up' ? 'followUp' : kind](message);
483
+ return this.writeSession(id, async (backend, entry) => {
484
+ const state = await backend.getState();
485
+ this.assertCurrent(entry, backend);
486
+ // Read and reserve without yielding: simultaneous idle inputs start exactly one turn.
487
+ const busy = entry.promptPending || entry.streaming || state.status === 'streaming';
488
+ const effective = busy ? (kind === 'prompt' ? 'follow-up' : kind) : 'prompt';
489
+ if (effective === 'prompt')
490
+ entry.promptPending = true;
491
+ try {
492
+ return await backend[effective === 'follow-up' ? 'followUp' : effective](message);
493
+ }
494
+ finally {
495
+ if (effective === 'prompt' && entry.backend === backend)
496
+ entry.promptPending = false;
497
+ }
498
+ });
441
499
  }
442
500
  /** Queued steering and follow-up messages waiting for the running turn. */
443
501
  // Both read through to the backend and leave the cache to `queue_update`, which stays correct when a
@@ -451,7 +509,10 @@ export class AgentManager {
451
509
  /** Drop queued messages and return them so the caller can restore them to its composer. */
452
510
  async clearQueue(id) {
453
511
  const entry = this.get(id);
454
- return entry.backend ? entry.backend.clearQueue() : emptyQueue();
512
+ return entry.backend ? this.writeSession(id, backend => backend.clearQueue()) : emptyQueue();
513
+ }
514
+ async moveQueue(id, from, index, expected) {
515
+ return this.writeSession(id, backend => backend.moveQueue(from, index, expected));
455
516
  }
456
517
  snapshotState(id) {
457
518
  const a = this.get(id);
@@ -490,20 +551,36 @@ export class AgentManager {
490
551
  this.globalListeners.add(listener);
491
552
  return () => this.globalListeners.delete(listener);
492
553
  }
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' });
554
+ dispose(id) {
555
+ const entry = this.get(id);
556
+ if (entry.unloading)
557
+ return entry.unloading;
558
+ // Reserve Stop synchronously. In-flight loads/replacements must validate before attaching.
559
+ entry.unloading = Promise.resolve()
560
+ .then(async () => {
561
+ if (entry.loading)
562
+ await entry.loading.catch(() => { });
563
+ const backend = entry.backend;
564
+ entry.backend = undefined;
565
+ entry.feed?.close();
566
+ entry.feed = undefined;
567
+ try {
568
+ await backend?.dispose();
569
+ }
570
+ finally {
571
+ entry.record.status = 'unloaded';
572
+ entry.streaming = false;
573
+ entry.promptPending = false;
574
+ entry.dialogRequests.clear();
575
+ entry.activity.reset();
576
+ entry.queue = emptyQueue();
577
+ this.publish(entry, { type: 'agent_unloaded' });
578
+ }
579
+ })
580
+ .finally(() => {
581
+ entry.unloading = undefined;
582
+ });
583
+ return entry.unloading;
507
584
  }
508
585
  async remove(id) {
509
586
  await this.dispose(id);
@@ -530,6 +607,11 @@ export class AgentManager {
530
607
  throw Object.assign(new Error('Timeline item not found'), { code: 'TIMELINE_ITEM_NOT_FOUND' });
531
608
  return { sessionId: source.sessionId, ...page };
532
609
  }
610
+ /** All timing records of the current branch; older sessions only have partial tool timings. */
611
+ async performance(id) {
612
+ const source = (await this.ensureLoaded(id)).timeline();
613
+ return { sessionId: source.sessionId, ...sessionPerformance(source.entries()) };
614
+ }
533
615
  /** The full body of one timeline item of the given session. It never changes. */
534
616
  async item(id, sessionId, itemId) {
535
617
  const source = (await this.ensureLoaded(id)).timeline(), body = source.sessionId === sessionId ? itemBody(buildTimeline(source.entries()), itemId) : undefined;
@@ -541,6 +623,57 @@ export class AgentManager {
541
623
  const info = await (await this.ensureLoaded(id)).getSystemPrompt();
542
624
  return { ...info, template: this.get(id).record.template ?? null };
543
625
  }
626
+ /** Prepare → persist → activate under the template lock. A failed save leaves the old session intact. */
627
+ async setTemplate(id, template, persist) {
628
+ const entry = this.get(id);
629
+ this.assertCurrent(entry);
630
+ if (entry.record.profile !== 'default' ||
631
+ entry.record.status === 'stopped' ||
632
+ entry.streaming ||
633
+ entry.writes)
634
+ throw Object.assign(new Error('System Prompt can only change before the first turn'), {
635
+ code: 'SESSION_STARTED'
636
+ });
637
+ if (entry.templateChanging)
638
+ throw Object.assign(new Error('System Prompt is being changed'), { code: 'SESSION_BUSY' });
639
+ entry.templateChanging = true;
640
+ try {
641
+ const old = await this.ensureLoaded(id);
642
+ this.assertCurrent(entry, old);
643
+ const session = await old.getSession();
644
+ if (session.entries.some(x => x.type === 'message'))
645
+ throw Object.assign(new Error('This session already has messages'), { code: 'SESSION_STARTED' });
646
+ const capabilities = await old.getCapabilities();
647
+ this.assertCurrent(entry, old);
648
+ const replacement = await this.factory(id, entry.record.cwd, entry.record.sessionFile, entry.record.profile, template ? { template } : {});
649
+ try {
650
+ const model = capabilities.model;
651
+ if (model?.provider && model?.id)
652
+ await replacement.setModel(model.provider, model.id);
653
+ if (typeof capabilities.thinkingLevel === 'string')
654
+ await replacement.setThinkingLevel(capabilities.thinkingLevel);
655
+ if (entry.record.sessionName)
656
+ await replacement.setSessionName(entry.record.sessionName);
657
+ const state = await replacement.getState();
658
+ this.assertCurrent(entry, old);
659
+ const record = { ...entry.record, ...state, template };
660
+ // Persistence is synchronous: no command can observe a choice that has not been committed.
661
+ persist(record);
662
+ entry.record = record;
663
+ this.connect(entry, replacement);
664
+ this.publish(entry, { type: 'agent_loaded' });
665
+ await old.dispose().catch(() => { });
666
+ return record;
667
+ }
668
+ catch (error) {
669
+ await replacement.dispose();
670
+ throw error;
671
+ }
672
+ }
673
+ finally {
674
+ entry.templateChanging = false;
675
+ }
676
+ }
544
677
  async capabilities(id) {
545
678
  return (await this.ensureLoaded(id)).getCapabilities();
546
679
  }
@@ -557,40 +690,49 @@ export class AgentManager {
557
690
  return { ...info, sessionName: name };
558
691
  }
559
692
  async compact(id, instructions) {
560
- return (await this.ensureLoaded(id)).compact(instructions);
693
+ return this.writeSession(id, backend => backend.compact(instructions));
561
694
  }
562
695
  async setModel(id, provider, modelId) {
563
- return (await this.ensureLoaded(id)).setModel(provider, modelId);
696
+ return this.writeSession(id, backend => backend.setModel(provider, modelId));
564
697
  }
565
698
  async setThinkingLevel(id, level) {
566
- return (await this.ensureLoaded(id)).setThinkingLevel(level);
699
+ return this.writeSession(id, backend => backend.setThinkingLevel(level));
567
700
  }
568
701
  async setSessionName(id, name) {
569
- await (await this.ensureLoaded(id)).setSessionName(name);
570
- this.get(id).record.sessionName = name.trim();
702
+ return this.writeSession(id, async (backend, entry) => {
703
+ await backend.setSessionName(name);
704
+ this.assertCurrent(entry, backend);
705
+ entry.record.sessionName = name.trim();
706
+ });
571
707
  }
572
708
  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;
709
+ return this.writeSession(id, async (backend, entry) => {
710
+ const result = await backend.navigate(entryId);
711
+ this.assertCurrent(entry, backend);
712
+ entry.feed?.sync(); // Moving the leaf back is no event; tell viewers to reload.
713
+ return result;
714
+ });
576
715
  }
577
716
  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 };
717
+ return this.writeSession(id, async (backend, source) => {
718
+ const result = await backend.fork(entryId);
719
+ this.assertCurrent(source, backend);
720
+ if (!result.sessionFile)
721
+ throw Object.assign(new Error('Session persistence is disabled'), { code: 'SESSION_NOT_PERSISTED' });
722
+ const ws = this.workspaces.get(source.record.workspaceId);
723
+ const agent = await this.create(source.record.workspaceId, pathRelative(ws.rootPath, source.record.cwd), result.sessionFile, source.record.profile, source.record.template);
724
+ return { agent, selectedText: result.selectedText };
725
+ });
584
726
  }
585
727
  async extensionResponse(id, requestId, value) {
586
- return (await this.ensureLoaded(id)).extensionResponse(requestId, value);
728
+ return this.writeSession(id, backend => backend.extensionResponse(requestId, value));
587
729
  }
588
730
  async listCommands(id) {
589
731
  return (await this.ensureLoaded(id)).listCommands();
590
732
  }
591
733
  /** Session-scoped bash. Output is streamed as `bash_execution_update` events and recorded in the Session. */
592
734
  async runBash(id, command, excludeFromContext = false) {
593
- return (await this.ensureLoaded(id)).runBash(command, excludeFromContext);
735
+ return this.writeSession(id, backend => backend.runBash(command, excludeFromContext));
594
736
  }
595
737
  async abortBash(id) {
596
738
  const entry = this.get(id);
@@ -81,6 +81,8 @@ export async function handleAgentRoutes(ctx, req, res, u, parts) {
81
81
  return send(200, {
82
82
  data: await ctx.agents.timeline(id, Number(u.searchParams.get('limit') ?? 50), u.searchParams.get('before') ?? undefined)
83
83
  });
84
+ if (req.method === 'GET' && parts[4] === 'performance' && parts.length === 5)
85
+ return send(200, { data: await ctx.agents.performance(id) });
84
86
  // /agents/:id/sessions/:sessionId/items/:itemId — items never change, and the session is part of the
85
87
  // URL so a cached body is never served for another one: let the browser keep it for good.
86
88
  if (req.method === 'GET' && parts[4] === 'sessions' && parts[6] === 'items' && parts.length === 8) {
@@ -90,6 +92,14 @@ export async function handleAgentRoutes(ctx, req, res, u, parts) {
90
92
  }
91
93
  if (req.method === 'GET' && parts[4] === 'system-prompt')
92
94
  return send(200, { data: await ctx.agents.systemPrompt(id) });
95
+ if (req.method === 'POST' && parts[4] === 'system-prompt') {
96
+ const b = await ctx.body();
97
+ if (b.templateId !== undefined && typeof b.templateId !== 'string')
98
+ throw Object.assign(new Error('templateId must be a string'), { code: 'INVALID_REQUEST' });
99
+ const template = b.templateId ? ctx.templates.snapshot(b.templateId) : undefined;
100
+ const record = await ctx.agents.setTemplate(id, template, candidate => ctx.metadata.commitAgentTemplate(candidate));
101
+ return send(200, { data: { template: record.template ?? null } });
102
+ }
93
103
  if (req.method === 'GET' && parts[4] === 'capabilities')
94
104
  return send(200, { data: await ctx.agents.capabilities(id) });
95
105
  if (req.method === 'GET' && parts[4] === 'session')
@@ -154,6 +164,15 @@ export async function handleAgentRoutes(ctx, req, res, u, parts) {
154
164
  return send(200, { data: await ctx.agents.queue(id) });
155
165
  if (req.method === 'DELETE')
156
166
  return send(200, { data: await ctx.agents.clearQueue(id) });
167
+ if (req.method === 'POST' && parts[5] === 'move') {
168
+ const { from, index, expected } = await ctx.body();
169
+ if ((from !== 'steering' && from !== 'followUp') ||
170
+ !Number.isSafeInteger(index) ||
171
+ index < 0 ||
172
+ typeof expected !== 'string')
173
+ throw Object.assign(new Error('from, index and expected are required'), { code: 'INVALID_REQUEST' });
174
+ return send(200, { data: await ctx.agents.moveQueue(id, from, index, expected) });
175
+ }
157
176
  }
158
177
  if (parts[4] === 'mcp') {
159
178
  const cwd = ctx.agents.get(id).record.cwd;
@@ -172,10 +191,17 @@ export async function handleAgentRoutes(ctx, req, res, u, parts) {
172
191
  return send(200, { data: { success: true } });
173
192
  }
174
193
  if (req.method === 'POST' && parts[4] === 'transcribe') {
194
+ ctx.agents.get(id);
195
+ const mime = String(req.headers['content-type'] ?? 'audio/webm').split(';')[0];
196
+ if (ctx.sottoStt.getSettings().enabled) {
197
+ if (mime !== 'application/octet-stream')
198
+ throw Object.assign(new Error('Sotto requires 16 kHz float32 PCM'), { code: 'INVALID_REQUEST' });
199
+ const audio = await ctx.binaryBody();
200
+ return send(200, { data: await ctx.sottoStt.transcribe(audio) });
201
+ }
175
202
  if (!ctx.voice)
176
203
  throw Object.assign(new Error('Voice service is disabled'), { code: 'VOICE_DISABLED' });
177
- ctx.agents.get(id);
178
- const audio = await ctx.binaryBody(), mime = String(req.headers['content-type'] ?? 'audio/webm').split(';')[0], filename = String(req.headers['x-audio-filename'] ?? 'recording.webm');
204
+ const audio = await ctx.binaryBody(), filename = String(req.headers['x-audio-filename'] ?? 'recording.webm');
179
205
  return send(200, { data: await ctx.voice.transcribe(audio, mime, filename) });
180
206
  }
181
207
  if (req.method === 'POST' && ['prompt', 'steer', 'follow-up', 'abort'].includes(parts[4])) {
@@ -35,7 +35,11 @@ export function errorStatus(code) {
35
35
  return 403;
36
36
  if (code === 'FILE_TOO_LARGE' || code === 'REQUEST_TOO_LARGE')
37
37
  return 413;
38
- if (code === 'VOICE_DISABLED' || code === 'VOICE_STT_DISABLED' || code === 'MAIL_NOTIFICATIONS_DISABLED')
38
+ if (code === 'VOICE_DISABLED' ||
39
+ code === 'VOICE_STT_DISABLED' ||
40
+ code === 'SOTTO_DISABLED' ||
41
+ code === 'SOTTO_UNAVAILABLE' ||
42
+ code === 'MAIL_NOTIFICATIONS_DISABLED')
39
43
  return 503;
40
44
  if (code?.endsWith('NOT_FOUND'))
41
45
  return 404;
@@ -10,6 +10,7 @@ const webFiles = {
10
10
  '/': { file: 'index.html', type: 'text/html; charset=utf-8' },
11
11
  '/index.html': { file: 'index.html', type: 'text/html; charset=utf-8' },
12
12
  '/app.js': { file: 'app.js', type: 'text/javascript; charset=utf-8' },
13
+ '/voice-pcm.js': { file: 'voice-pcm.js', type: 'text/javascript; charset=utf-8' },
13
14
  '/backend-state.js': { file: 'backend-state.js', type: 'text/javascript; charset=utf-8' },
14
15
  '/hid-keyboard.js': { file: 'hid-keyboard.js', type: 'text/javascript; charset=utf-8' },
15
16
  '/activity-view.js': { file: 'activity-view.js', type: 'text/javascript; charset=utf-8' },
@@ -176,7 +176,8 @@ export class MetadataStore {
176
176
  writeAgent(record, archivedAt) {
177
177
  this.database
178
178
  .prepare(
179
- // `template` is written on insert only: an Agent's system prompt template never changes.
179
+ // Ordinary state/index writes must not overwrite the conversation's template snapshot.
180
+ // Only commitAgentTemplate may change it after creation.
180
181
  `INSERT INTO agents(id,workspace_id,session_id,session_file,session_name,cwd,status,profile,template,created_at,last_active_at,archived_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET workspace_id=excluded.workspace_id,session_id=excluded.session_id,session_file=excluded.session_file,session_name=excluded.session_name,cwd=excluded.cwd,status=excluded.status,profile=excluded.profile,last_active_at=excluded.last_active_at,archived_at=excluded.archived_at`)
181
182
  .run(record.agentId, record.workspaceId, record.sessionId, record.sessionFile ?? null, record.sessionName ?? null, record.cwd, record.status, record.profile ?? 'default', record.template ? JSON.stringify(record.template) : null, asTime(record.createdAt), asTime(record.lastActiveAt), archivedAt);
182
183
  }
@@ -186,6 +187,18 @@ export class MetadataStore {
186
187
  this.pendingAgents.delete(record.agentId);
187
188
  this.writeAgent(record, null);
188
189
  }
190
+ /** Commit the validated pre-conversation choice and its session identity together. */
191
+ commitAgentTemplate(record) {
192
+ this.database.transaction(() => {
193
+ const updated = this.database
194
+ .prepare('UPDATE agents SET template=? WHERE id=? AND archived_at IS NULL')
195
+ .run(record.template ? JSON.stringify(record.template) : null, record.agentId);
196
+ if (!updated.changes)
197
+ throw Object.assign(new Error('Agent not found'), { code: 'AGENT_NOT_FOUND' });
198
+ this.writeAgent(record, null);
199
+ })();
200
+ this.pendingAgents.delete(record.agentId);
201
+ }
189
202
  scheduleAgent(record) {
190
203
  if (this.archivedAgents.has(record.agentId))
191
204
  return;
@@ -1,6 +1,7 @@
1
1
  import { createAgentSession, DefaultResourceLoader, getAgentDir, SessionManager } from '@earendil-works/pi-coding-agent';
2
2
  import { EventEmitter } from 'node:events';
3
3
  import { randomUUID } from 'node:crypto';
4
+ import { PERFORMANCE_ENTRY, SessionPerformanceTracker } from './session-performance.js';
4
5
  import { createCollabExtension } from './collab/pi-extension.js';
5
6
  /** Older Pi builds resolved steer()/followUp() with undefined; treat anything but an explicit `handled` as queued. */
6
7
  const normalizeDisposition = (value) => value === 'handled' ? 'handled' : 'queued';
@@ -16,6 +17,8 @@ export class SdkBackend {
16
17
  this.session = session;
17
18
  this.agentId = agentId;
18
19
  this.cwd = cwd;
20
+ const performance = new SessionPerformanceTracker(run => this.session.sessionManager.appendCustomEntry(PERFORMANCE_ENTRY, run));
21
+ this.session.subscribe(event => performance.apply(event));
19
22
  }
20
23
  static async create(agentId, cwd, sessionFile, profile = 'default', collabBridge, options = {}) {
21
24
  const sessionManager = sessionFile ? SessionManager.open(sessionFile) : SessionManager.create(cwd);
@@ -81,6 +84,31 @@ export class SdkBackend {
81
84
  const cleared = this.session.clearQueue();
82
85
  return { steering: [...cleared.steering], followUp: [...cleared.followUp] };
83
86
  }
87
+ async moveQueue(from, index, expected) {
88
+ const before = {
89
+ steering: [...this.session.getSteeringMessages()],
90
+ followUp: [...this.session.getFollowUpMessages()]
91
+ };
92
+ if (!this.session.isStreaming || before[from][index] !== expected)
93
+ throw Object.assign(new Error('Queued message has changed or already been consumed'), {
94
+ code: 'QUEUE_CHANGED'
95
+ });
96
+ const to = from === 'steering' ? 'followUp' : 'steering';
97
+ before[from].splice(index, 1);
98
+ before[to].push(expected);
99
+ if ([...before.steering, ...before.followUp].some(text => text.startsWith('/')))
100
+ throw Object.assign(new Error('Slash commands cannot be moved safely'), { code: 'QUEUE_CHANGED' });
101
+ // Pi exposes clearQueue(), but no per-message move. Clear and requeue in the same JS turn:
102
+ // steer()/followUp() enqueue synchronously before their promises settle. No model callback can
103
+ // drain the half-updated queue between these calls. Never replace a stale queue by index alone.
104
+ this.session.clearQueue();
105
+ const pending = [
106
+ ...before.steering.map(text => this.session.steer(text)),
107
+ ...before.followUp.map(text => this.session.followUp(text))
108
+ ];
109
+ await Promise.all(pending);
110
+ return this.getQueue();
111
+ }
84
112
  abort() {
85
113
  return this.session.abort();
86
114
  }