Compare commits

...
18 Commits
Author SHA1 Message Date
snowspeederandClaude Fable 5 28a2629c43 feat: speak answers sentence by sentence and never speak reasoning
CI / Verify and package (ubuntu-latest) (push) Canceled after 0s
CI / Verify and package (windows-latest) (push) Canceled after 0s
Segment the final answer as it streams and synthesize each completed
sentence immediately, so a listener hears the reply begin while the model
is still writing it. Sentences share the progress clips' FIFO chain and
output-sequence counter; only text left unspoken (a hit budget) is
synthesized after settle, so audio is never sent twice. A message that
grows a tool call stops answer-streaming without re-speaking what already
played.

Reasoning models inline their chain of thought as <think> blocks in the
same delta stream. Strip it while streaming -- carrying a partial tag
across delta boundaries -- so it is neither spoken nor shown as answer
text.

The fixed "Hang on while I work on that." is now a rotation of short
natural phrases that never repeats twice running, and a long silent think
gets a spoken "still on it" roughly every 45 seconds until real speech
begins. Turn timeout rises to five minutes to match the client.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-07 14:20:21 -04:00
snowspeederandClaude Fable 5 a7033f9076 feat: speak a progress update during long voice turns
CI / Verify and package (ubuntu-latest) (push) Canceled after 0s
CI / Verify and package (windows-latest) (push) Canceled after 0s
After two seconds of agent work, send one agent.progress frame with a
synthesized "Hang on while I work on that." clip over the same binary
audio channel (shared output-sequence counter), at most once per turn;
screen-only agent.working heartbeats continue every 15 seconds. This is
the change the 15:11 rebuild already shipped to the running server —
committing it pins the protocol addition to the client that now
tolerates and plays it.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-06 23:10:07 -04:00
snowspeeder a71cb8334a feat: extend voice API workspace and speech controls
CI / Verify and package (ubuntu-latest) (push) Canceled after 0s
CI / Verify and package (windows-latest) (push) Canceled after 0s
2026-08-06 12:09:34 -04:00
snowspeeder 1adb459e04 feat: harden voice API conversations and progress 2026-08-06 00:42:55 -04:00
snowspeeder 0793fa0ac4 feat: add authenticated voice pipeline API docs
CI / Verify and package (ubuntu-latest) (push) Canceled after 0s
CI / Verify and package (windows-latest) (push) Canceled after 0s
2026-08-04 19:09:33 -04:00
snowspeeder a2e4f1d576 chore: document fork workflow and ignore local artifacts
CI / Verify and package (ubuntu-latest) (push) Canceled after 0s
CI / Verify and package (windows-latest) (push) Canceled after 0s
2026-08-04 16:25:32 -04:00
snowspeeder 9627453e23 fix: keep Azure voice mode active between turns
CI / Verify and package (ubuntu-latest) (push) Canceled after 0s
CI / Verify and package (windows-latest) (push) Canceled after 0s
2026-08-04 16:22:55 -04:00
snowspeeder c3e0011378 feat: add Azure Speech voice mode
CI / Verify and package (ubuntu-latest) (push) Canceled after 0s
CI / Verify and package (windows-latest) (push) Canceled after 0s
2026-08-04 07:28:48 -04:00
Federico Jaramillo Martinez c09b67d15a feat(sessions): show thinking level in assistant chat bubble metadata
CI / Verify and package (ubuntu-latest) (push) Canceled after 0s
CI / Verify and package (windows-latest) (push) Canceled after 0s
Attribute the thinking level in effect to each assistant message: from
thinking_level_change branch entries for history, from the live session
level for streamed message.end events and join-time stream snapshots.
The chat metadata row renders it after the model; thinking "off" stays
hidden so non-reasoning bubbles are unchanged.
2026-07-30 12:55:11 +02:00
Federico Jaramillo Martinez c2b7cce2d3 feat(sessions): inherit thinking level in spawned sessions
spawn_session and spawn_subsession now forward the dispatching session's
current thinking level to the new session, clamped by pi to the spawned
model's capabilities, instead of falling back to the configured default.
2026-07-30 12:54:24 +02:00
Federico Jaramillo Martinez 7103bfcb4c feat(sessions): streamline bulk selection toolbar
Merge Select visible / Clear visible / Clear into one binary toggle
(Select visible when empty, Clear selected otherwise) and drop the
redundant Done button; selection mode closes from the same heading
toggle that opened it. Shorten Archive/Delete labels so the toolbar
fits one line on narrow screens.
2026-07-30 00:21:53 +02:00
Federico Jaramillo Martinez 5f4d81352f feat(sessions): optional model for spawn tools and # model completion in composer
spawn_session and spawn_subsession accept an optional model parameter as
an exact provider/model-id (strict matching; unknown specs fail listing
available models; omitting it keeps the inherited model). The chat
composer opens a model completion menu on # and inserts a
#provider/model-id reference into the draft, which agents forward as
the model parameter.
2026-07-29 23:58:38 +02:00
Federico Jaramillo Martinez 9b8728a5d6 chore(release): v1.202607.3 2026-07-29 13:50:42 +02:00
Federico Jaramillo Martinez c3aeef23f3 fix(relays): keep document tab strip scroll position on tab switch 2026-07-29 13:47:24 +02:00
Federico Jaramillo Martinez 93249c8bc4 Merge pull request #109 from jmfederico/fix/issue-106-extension-dialogs
feat(sessions): answerable Pi extension dialogs (ctx.ui confirm/select/input)
2026-07-29 09:07:26 +02:00
Federico Jaramillo Martinez 7a426571b8 docs(sessions): document settled extension dialog cards as dismiss-stay records 2026-07-29 07:52:21 +02:00
Federico Jaramillo Martinez 5420869c52 fix(sessions): close extension dialog lifecycle edge cases
Post-review hardening for the extension dialog feature:

- dispose() and closeActive() now settle startup-parked session_start
  dialogs before awaiting pending opens, so daemon shutdown or closing a
  session whose open is parked on a dialog can no longer block behind the
  dialog timeout (infinite with extensionDialogsTimeoutMs: 0).
- A failed create now drops its dead dialog cards, closes the
  early-subscribed socket, and ignores late dialog frames instead of
  leaving an unanswerable card on the failed row.
- The pending-start status resync checks its staleness guard before
  applying the unordered snapshot, so a late response can no longer
  clobber the post-swap session state.
- The dialog countdown no longer queues one screen-reader announcement
  per second (decorative; the daemon-owned dialog.closed event is the
  real signal) and no longer renders "1h 60m" near hour boundaries.
2026-07-29 07:52:21 +02:00
Federico Jaramillo Martinez 5759201a39 docs: document Pi extension dialogs and extensionDialogsTimeoutMs
Add the close-out documentation for extension dialog support:

- docs/config.md (+ synchronized config.html): extensionDialogsTimeoutMs
  in the config matrix, reload/restart guidance, the global config
  example, and a new Extension dialogs key-detail section covering the
  unattended-dialog safety valve (default 5 min, 0 = forever, effective
  deadline is the sooner of the extension's own timeout and this knob).
- docs/plugins.md (+ synchronized plugins.html): new Pi extension
  dialogs in PI WEB behavior note for extension authors — confirm/select/
  input render inline in the transcript and resolve with the real answer,
  answers use a dedicated daemon channel (never the prompt queue, so
  tool_call hooks park safely), session_start dialogs are reachable
  during create and open, reload rehydration, first-answer-wins across
  tabs, abort/runtime-replacement settlement, and the reload-mid-startup
  browser-local caveat.
- Add the extension-dialogs changeset (patch) for the release notes.
2026-07-29 07:52:21 +02:00
82 changed files with 5957 additions and 299 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"@jmfederico/pi-web": patch
---
Add an `ask_user` session tool that lets agents post structured question sets as one chat-native browser form. The form uses the transcript's single scroll area, keeps its header visible, and always gives every question a Custom free-text answer with mobile-safe text sizing. Agents end their run while the form waits; users can submit full or partial answers, unanswered questions are reported explicitly, sending an ordinary chat message voids the open form, pending forms survive browser and web/API reconnects, and closed forms remain readable in the transcript. Disable the tool from **Settings → Session daemon**, with `askUser: false`, or with `PI_WEB_ASK_USER=false`.
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Show the thinking level in assistant chat bubble metadata next to the model and timestamp, for both history and live messages. Bubbles from turns with thinking off stay unchanged.
@@ -1,5 +0,0 @@
---
"@jmfederico/pi-web": patch
---
Let chat markdown tables keep their natural width and scroll horizontally instead of being squeezed into the chat column, making them readable on mobile.
-5
View File
@@ -1,5 +0,0 @@
---
"@jmfederico/pi-web": patch
---
Turn the bundled Info plugin panel into an always-available PI WEB status view: it now shows the running and installed versions, installation kind and path, release state, per-service health, and machine and workspace details from host-provided status, plus a "Copy PI WEB Diagnostics" action that copies a plain-text summary for bug reports.
@@ -1,5 +0,0 @@
---
"@jmfederico/pi-web": patch
---
Let an already-known provider extension refresh its own model list after daemon startup. Previously every provider registration made after the global bootstrap was ignored, so a provider that fetched an updated model catalog on session start never had those models appear. A registration is now applied when it matches the provider's recorded startup configuration in every respect except the model list; anything else — a new provider, a changed provider base URL, API key, API type, headers, or auth surface, a native provider registration, or an unregistration — is still ignored to keep project-level provider configuration from leaking between workspaces. Documented the refreshed policy under Pi extension provider baseline in the configuration reference.
@@ -1,5 +0,0 @@
---
"@jmfederico/pi-web": patch
---
Add action-palette commands for selecting a session's model and thinking level, with support for assigning custom shortcuts in Settings.
@@ -1,5 +0,0 @@
---
"@jmfederico/pi-web": patch
---
Show cross-workspace session relationships in the session list. A session whose parent lives in another worktree now names that parent's workspace or branch instead of only reporting an unavailable parent, and offers a "Go to parent session" action that switches to the owning workspace and selects the parent. A session with children in other workspaces of the same project now shows how many, so a parent no longer looks childless when its children are not nested beneath it.
-5
View File
@@ -1,5 +0,0 @@
---
"@jmfederico/pi-web": patch
---
Add `files.listFiles(path)` to the stable plugin API so workspace panel and label plugins can list workspace directory entries on local and federated machines.
@@ -1,5 +0,0 @@
---
"@jmfederico/pi-web": patch
---
Add `state.selectedMachine` to the stable plugin runtime state so plugin actions and other runtime callbacks can read the selected machine's identity, not just workspace panel contexts.
-5
View File
@@ -1,5 +0,0 @@
---
"@jmfederico/pi-web": patch
---
Add a built-in Relays plugin: a read-only workspace tab (and **Open Workspace Relays** action) that browses `.pi-web/relays/` packets, with a most-recent relay picker, ordered document tabs, sanitized markdown rendering, and truncation notices.
@@ -1,7 +0,0 @@
---
"@jmfederico/pi-web": patch
---
Remove the legacy session archive migration from session daemon startup. Each `PI_WEB_DATA_DIR` data directory is independent: pointing PI WEB at a new data directory starts there with empty registries and no session archives.
You are only affected if you have session archives created before July 2026 in the default `~/.pi-web` data directory and you newly set a custom `PI_WEB_DATA_DIR`. To carry those archives over, stop PI WEB, then copy `archived-sessions.json` and the `archived-sessions/` directory from the old data directory into the new one.
-5
View File
@@ -1,5 +0,0 @@
---
"@jmfederico/pi-web": patch
---
Stop the session and workspace lists from re-scrolling to the selected row on live data refreshes, such as message-count updates while a session streams or workspace topology refreshes. The lists now scroll the selection into view only when the selection moves to a different row, an archived session is revealed, a restored session moves back to the current section, or a collapsed section expands.
@@ -1,5 +0,0 @@
---
"@jmfederico/pi-web": patch
---
Say what a slow session start is waiting on. While a session is being created or opened, the activity line now names the current startup step — starting the Pi session, or loading session extensions — and adds a note when provider model lists happen to be refreshing at the same time. When nothing can be attributed, the previous generic wording is kept rather than guessing a cause.
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Sessions started via `spawn_session` and `spawn_subsession` now inherit the spawning session's thinking level instead of falling back to the pi default, clamped to the child model's capabilities.
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Let agents pick a model when delegating work: `spawn_session` and `spawn_subsession` accept an optional `model` parameter as an exact `provider/model-id` (an unknown value is rejected; omitting it keeps the inherited model). In the chat composer, typing `#` opens a model completion menu that inserts a `#provider/model-id` reference into the draft, which agents forward as that parameter.
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Streamline the session list bulk-selection toolbar: the Select visible / Clear visible / Clear buttons are now a single toggle that offers "Select visible" when nothing is selected and "Clear selected" otherwise, and the redundant Done button is gone — selection mode closes from the same ☑ heading button that opened it. and "Archive selected" / "Delete selected" are shortened to "Archive" / "Delete". The slimmer toolbar no longer wraps to two lines on narrow sidebars.
@@ -1,5 +0,0 @@
---
"@jmfederico/pi-web": patch
---
Give every navigation row a single activity indicator that also carries unread state. When sessions beneath a workspace, project, or machine row have unread completions, the row's indicator becomes a static accent ring around the activity dot — or a filled accent dot while idle — instead of a separate dot next to the name. Session rows now surface unread state even while busy or sending, and the "N unread" header and mobile Sessions badge count busy unread sessions too.
@@ -1,5 +0,0 @@
---
"@jmfederico/pi-web": patch
---
Add "Mark as read" actions for unread sessions: a per-session item in the session row ⋯ menu (shown only for unread sessions) and a bulk "Mark read" button in the multi-select bar that marks every unread selected session as read.
@@ -1,5 +0,0 @@
---
"@jmfederico/pi-web": patch
---
Add copy buttons to the workspace menu details so the workspace path and branch can be copied to the clipboard with one click, matching the copy affordances already available in chats.
-5
View File
@@ -1,5 +0,0 @@
---
"@jmfederico/pi-web": patch
---
Pick up git worktrees created or removed outside PI WEB without any user action. The selected project's workspace list is re-read whenever the browser tab regains focus or becomes visible, on local and remote machines, keeping the current workspace, session, and scroll position untouched. Worktrees whose checkout directory no longer exists are hidden instead of being offered as selectable workspaces.
+19
View File
@@ -14,3 +14,22 @@ dist/
# Local runtime attachment uploads (created by the chat composer "save to folder" mode).
.pi-web/
# Local PI subagent run transcripts and artifacts.
.pi-subagents/
# Test, build, and browser-automation output.
coverage/
test-results/
playwright-report/
.vite/
*.tsbuildinfo
# Local credentials and TLS material. Keep examples explicitly versionable.
.env.*
!.env.example
*.pem
*.key
*.crt
*.p12
*.pfx
+11
View File
@@ -1,5 +1,16 @@
# Agent Notes
## Fork initialization and upstream sync
This repository is a personal fork maintained under the `snowspeeder` Gitea account.
- `origin` is the writable Gitea repository: `snowspeeder/pi-web`.
- `upstream` is the canonical project: `https://github.com/jmfederico/pi-web.git`.
- Keep fork-specific work in focused commits and branches. Do not force-push shared branches.
- To bring in project updates, run `git fetch upstream`, merge or rebase `upstream/main` into the relevant local branch, resolve conflicts, test, then push to `origin`.
- Do not push changes to `upstream`.
This project is expected to run locally using split systemd user services:
- `pi-web-sessiond.service` runs `npm run start:sessiond` in non-autoreload, non-auto-restart mode.
+26
View File
@@ -1,5 +1,31 @@
# @jmfederico/pi-web
## 1.202607.3
### Patch Changes
- 9191f59: Add an `ask_user` session tool that lets agents post structured question sets as one chat-native browser form. The form uses the transcript's single scroll area, keeps its header visible, and always gives every question a Custom free-text answer with mobile-safe text sizing. Agents end their run while the form waits; users can submit full or partial answers, unanswered questions are reported explicitly, sending an ordinary chat message voids the open form, pending forms survive browser and web/API reconnects, and closed forms remain readable in the transcript. Disable the tool from **Settings → Session daemon**, with `askUser: false`, or with `PI_WEB_ASK_USER=false`.
- 111db63: Let chat markdown tables keep their natural width and scroll horizontally instead of being squeezed into the chat column, making them readable on mobile.
- 5759201: Support Pi extension dialogs in the browser: `ctx.ui.confirm()`, `ctx.ui.select()`, and `ctx.ui.input()` now render as cards inline in the session transcript and resolve with the user's actual answer — including dialogs opened from `session_start` hooks while the session is still starting and from in-flight `tool_call` hooks, which previously resolved `false` immediately despite `hasUI === true`. Answers travel over a dedicated session-daemon channel rather than the prompt queue, so a dialog parked inside a `tool_call` hook cannot deadlock the run. Open dialogs survive browser reloads, the first answer wins across browser tabs, and unanswered dialogs settle safely on run abort, runtime replacement, or timeout. Adds the `extensionDialogsTimeoutMs` config key (default 5 minutes, `0` waits forever) as the unattended-dialog safety valve; dialog support is always on. Other `ExtensionUIContext` surfaces (widgets, status, editor, `custom`) remain unimplemented.
- 8517800: Turn the bundled Info plugin panel into an always-available PI WEB status view: it now shows the running and installed versions, installation kind and path, release state, per-service health, and machine and workspace details from host-provided status, plus a "Copy PI WEB Diagnostics" action that copies a plain-text summary for bug reports.
- 531ccf7: Let an already-known provider extension refresh its own model list after daemon startup. Previously every provider registration made after the global bootstrap was ignored, so a provider that fetched an updated model catalog on session start never had those models appear. A registration is now applied when it matches the provider's recorded startup configuration in every respect except the model list; anything else — a new provider, a changed provider base URL, API key, API type, headers, or auth surface, a native provider registration, or an unregistration — is still ignored to keep project-level provider configuration from leaking between workspaces. Documented the refreshed policy under Pi extension provider baseline in the configuration reference.
- ce4b469: Add action-palette commands for selecting a session's model and thinking level, with support for assigning custom shortcuts in Settings.
- 69b125b: Show cross-workspace session relationships in the session list. A session whose parent lives in another worktree now names that parent's workspace or branch instead of only reporting an unavailable parent, and offers a "Go to parent session" action that switches to the owning workspace and selects the parent. A session with children in other workspaces of the same project now shows how many, so a parent no longer looks childless when its children are not nested beneath it.
- d19fca4: Add `files.listFiles(path)` to the stable plugin API so workspace panel and label plugins can list workspace directory entries on local and federated machines.
- 8517800: Add `state.selectedMachine` to the stable plugin runtime state so plugin actions and other runtime callbacks can read the selected machine's identity, not just workspace panel contexts.
- 87c0998: Add a built-in Relays plugin: a read-only workspace tab (and **Open Workspace Relays** action) that browses `.pi-web/relays/` packets, with a most-recent relay picker, ordered document tabs, sanitized markdown rendering, and truncation notices.
- c3aeef2: Keep the relays panel document tab strip's horizontal scroll position when switching documents, instead of jumping back to the left edge on every tab click.
- 5c3461d: Remove the legacy session archive migration from session daemon startup. Each `PI_WEB_DATA_DIR` data directory is independent: pointing PI WEB at a new data directory starts there with empty registries and no session archives.
You are only affected if you have session archives created before July 2026 in the default `~/.pi-web` data directory and you newly set a custom `PI_WEB_DATA_DIR`. To carry those archives over, stop PI WEB, then copy `archived-sessions.json` and the `archived-sessions/` directory from the old data directory into the new one.
- 76f292c: Stop the session and workspace lists from re-scrolling to the selected row on live data refreshes, such as message-count updates while a session streams or workspace topology refreshes. The lists now scroll the selection into view only when the selection moves to a different row, an archived session is revealed, a restored session moves back to the current section, or a collapsed section expands.
- 49e7c39: Say what a slow session start is waiting on. While a session is being created or opened, the activity line now names the current startup step — starting the Pi session, or loading session extensions — and adds a note when provider model lists happen to be refreshing at the same time. When nothing can be attributed, the previous generic wording is kept rather than guessing a cause.
- 8af637b: Give every navigation row a single activity indicator that also carries unread state. When sessions beneath a workspace, project, or machine row have unread completions, the row's indicator becomes a static accent ring around the activity dot — or a filled accent dot while idle — instead of a separate dot next to the name. Session rows now surface unread state even while busy or sending, and the "N unread" header and mobile Sessions badge count busy unread sessions too.
- 8af637b: Add "Mark as read" actions for unread sessions: a per-session item in the session row ⋯ menu (shown only for unread sessions) and a bulk "Mark read" button in the multi-select bar that marks every unread selected session as read.
- 4a51503: Add copy buttons to the workspace menu details so the workspace path and branch can be copied to the clipboard with one click, matching the copy affordances already available in chats.
- 8a24a7c: Pick up git worktrees created or removed outside PI WEB without any user action. The selected project's workspace list is re-read whenever the browser tab regains focus or becomes visible, on local and remote machines, keeping the current workspace, session, and scroll position untouched. Worktrees whose checkout directory no longer exists are hidden instead of being offered as selectable workspaces.
## 1.202607.2
### Patch Changes
+67 -1
View File
@@ -102,6 +102,8 @@
<a href="#pi-extension-provider-baseline">Pi extension providers</a>
<a href="#catalog-refresh">Model catalog refresh</a>
<a href="#session-tools">Session tools</a>
<a href="#extension-dialogs">Extension dialogs</a>
<a href="#prompt-completions">Prompt completions</a>
<a href="#completion-tools">Completion tools</a>
</aside>
@@ -173,7 +175,7 @@
<ul>
<li><code>host</code> / <code>port</code>: restart the gateway web/API service or process.</li>
<li><code>maxUploadBytes</code>: restart both the web/API process and the session daemon on that machine.</li>
<li><code>agent.command</code> / <code>agent.dir</code> / <code>spawnSessions</code> / <code>subsessions</code> / <code>askUser</code>: restart the session daemon on that machine.</li>
<li><code>agent.command</code> / <code>agent.dir</code> / <code>spawnSessions</code> / <code>subsessions</code> / <code>askUser</code> / <code>extensionDialogsTimeoutMs</code>: restart the session daemon on that machine.</li>
<li><code>pathAccess</code>: applies on the next request; existing file views may need a browser refresh.</li>
<li><code>uploads.defaultFolder</code>: applies to newly opened Files upload dialogs and new direct drag/drop batches after config/workspace refresh.</li>
<li><code>plugins</code>: reload the browser tab after changing PI WEB plugin enablement.</li>
@@ -213,6 +215,7 @@
"spawnSessions": true,
"subsessions": false,
"askUser": true,
"extensionDialogsTimeoutMs": 300000,
"plugins": {
"workspace-tasks": { "enabled": true },
"updates": { "enabled": true },
@@ -376,6 +379,14 @@
<td>Not supported locally</td>
<td>Restart session daemon on that machine</td>
</tr>
<tr>
<td>Extension dialog auto-cancel timeout</td>
<td><code>extensionDialogsTimeoutMs</code></td>
<td></td>
<td>Global/session daemon</td>
<td>Not supported locally</td>
<td>Restart session daemon on that machine</td>
</tr>
<tr>
<td>PI WEB plugin enablement/settings</td>
<td><code>plugins.&lt;id&gt;.enabled</code>, <code>plugins.&lt;id&gt;.settings</code></td>
@@ -795,6 +806,16 @@
available when the child stops. Included output and transcripts follow a labeled marker and come last,
after PI WEB guidance.
</p>
<p>
Both <code>spawn_session</code> and <code>spawn_subsession</code> accept an optional
<code>model</code> parameter, given as an exact <code>provider/model-id</code> such as
<code>anthropic/claude-sonnet-4-5</code>. When set, the new session starts on that model instead of
inheriting the dispatching session's model. The match is strict: an unknown or malformed value is
rejected with an error. A <code>#provider/model-id</code> reference in the prompt (see
<a href="#prompt-completions">Prompt completions</a>) is how users ask for a specific model; agents
forward that reference as this parameter. The new session also inherits the dispatching session's
thinking level, clamped to its model's capabilities.
</p>
<p>
In <strong>Settings → Session daemon</strong>, these keys are saved on the selected machine. Restart the
session daemon on that machine after changing them.
@@ -842,6 +863,51 @@
</div>
</section>
<section id="extension-dialogs">
<h2>Extension dialogs</h2>
<p>
Pi extensions can ask the user questions from <code>ctx.ui.confirm()</code>,
<code>ctx.ui.select()</code>, and <code>ctx.ui.input()</code> — including from
<code>session_start</code> hooks and in-flight <code>tool_call</code> hooks. PI WEB renders these dialogs
inline in the session transcript and answers them through a dedicated session-daemon channel, never the
prompt queue, so a dialog parked inside a <code>tool_call</code> hook cannot deadlock the run. Dialog
support is always on; there is no enable flag. See
<a href="plugins#pi-extension-dialogs">Pi extension dialogs in PI WEB</a> for behavior details and author
guidance.
</p>
<p>
<code>extensionDialogsTimeoutMs</code> is the unattended-dialog safety valve: how long the session daemon
waits for an answer before settling the dialog with its kind's cancel value (<code>false</code> for
confirm, <code>undefined</code> for select and input). It defaults to <code>300000</code> (5 minutes);
set it to <code>0</code> to wait forever. An extension's own <code>timeout</code> option still applies,
and the effective deadline is the sooner of the two.
</p>
<div class="callout warning">
<strong>Restart required:</strong> <code>extensionDialogsTimeoutMs</code> is edited directly in the global
config file. Restart the session daemon after changing it — for the systemd user service, run
<code>systemctl --user restart pi-web-sessiond</code>.
</div>
</section>
<section id="prompt-completions">
<h2>Prompt completions</h2>
<p>The chat composer opens completion menus on three trigger characters:</p>
<ul>
<li><code>/</code> at the very start of the draft completes session commands.</li>
<li>
<code>@</code> completes file paths: <code>@</code> for tracked files, <code>@ </code> (at, then
space) or <code>!@</code> for all files. Picking one inserts an <code>@path</code> reference into the
draft, quoted automatically when the path contains spaces.
</li>
<li>
<code>#</code> completes the models available to the session, filtered case-insensitively as you type
(at most 12 entries). Picking one inserts a <code>#provider/model-id</code> reference into the draft,
which tells agents the request should run on that model — for example as the <code>model</code>
parameter of <code>spawn_session</code>.
</li>
</ul>
</section>
<section id="completion-tools">
<h2>Optional completion tools</h2>
+21 -1
View File
@@ -39,7 +39,7 @@ Process restarts depend on the key:
- `host` / `port`: restart the gateway web/API service or process.
- `maxUploadBytes`: restart both the web/API process and the session daemon on that machine.
- `agent.command` / `agent.dir` / `spawnSessions` / `subsessions` / `askUser`: restart the session daemon on that machine.
- `agent.command` / `agent.dir` / `spawnSessions` / `subsessions` / `askUser` / `extensionDialogsTimeoutMs`: restart the session daemon on that machine.
- `pathAccess`: applies on the next request; existing file views may need a browser refresh.
- `uploads.defaultFolder`: applies to newly opened Files upload dialogs and new direct drag/drop batches after config/workspace refresh.
- `plugins`: reload the browser tab after changing PI WEB plugin enablement.
@@ -66,6 +66,7 @@ Process restarts depend on the key:
"spawnSessions": true,
"subsessions": false,
"askUser": true,
"extensionDialogsTimeoutMs": 300000,
"plugins": {
"workspace-tasks": { "enabled": true },
"updates": { "enabled": true },
@@ -118,6 +119,7 @@ Rows with JSON key `—` are runtime-only environment variables, not config-file
| Agent can spawn sessions | `spawnSessions` | `PI_WEB_SPAWN_SESSIONS` | Global/session daemon | Not supported locally | Restart session daemon on that machine |
| Tracked subsessions (beta) | `subsessions` | `PI_WEB_SUBSESSIONS` | Global/session daemon | Not supported locally; also requires `spawnSessions` | Restart session daemon on that machine |
| Agent can post question forms | `askUser` | `PI_WEB_ASK_USER` | Global/session daemon | Not supported locally | Restart session daemon on that machine |
| Extension dialog auto-cancel timeout | `extensionDialogsTimeoutMs` | — | Global/session daemon | Not supported locally | Restart session daemon on that machine |
| Plugin enablement/settings | `plugins.<id>.enabled`, `plugins.<id>.settings` | — | Global | Not core local config; plugins may read their own project files | Reload browser tab |
| Keyboard shortcuts | `shortcuts.<actionId>` | — | Global | Not supported locally | Applies after settings save/config refresh |
| Project config version | `version` | — | Project | Project-local only; must be `1` when present | Next project-config read |
@@ -271,6 +273,8 @@ A completion notice wakes an idle parent or queues behind in-flight work. Each n
`list_subsessions`, `check_subsession`, and `read_subsession` never yield or change control flow. They are for deliberate inspection or recovery, not completion polling. While a child works, agent-facing `check_subsession` and `read_subsession` withhold partial output and direct the parent to continue independent work or yield at the join point. Output becomes available when the child stops. Included output and transcripts follow a labeled marker and come last, after PI WEB guidance.
Both `spawn_session` and `spawn_subsession` accept an optional `model` parameter, given as an exact `provider/model-id` such as `anthropic/claude-sonnet-4-5`. When set, the new session starts on that model instead of inheriting the dispatching session's model. The match is strict: an unknown or malformed value is rejected with an error. A `#provider/model-id` reference in the prompt (see [Prompt completions](#prompt-completions)) is how users ask for a specific model; agents forward that reference as this parameter. The new session also inherits the dispatching session's thinking level, clamped to its model's capabilities.
In **Settings → Session daemon**, these keys are saved on the selected machine. Restart the session daemon on that machine after changing them.
#### `askUser` and `ask_user`
@@ -289,6 +293,14 @@ Sending an ordinary chat message while a form is open voids the form: the card c
Restart the session daemon after changing `askUser` or after upgrading PI WEB to a version that introduces this tool. For the systemd user service, run `systemctl --user restart pi-web-sessiond`.
### Extension dialogs
Pi extensions can ask the user questions from `ctx.ui.confirm()`, `ctx.ui.select()`, and `ctx.ui.input()` — including from `session_start` hooks and in-flight `tool_call` hooks. PI WEB renders these dialogs inline in the session transcript and answers them through a dedicated session-daemon channel, never the prompt queue, so a dialog parked inside a `tool_call` hook cannot deadlock the run. Dialog support is always on; there is no enable flag. See [Pi extension dialogs in PI WEB](https://pi-web.dev/plugins#pi-extension-dialogs) for behavior details and author guidance.
`extensionDialogsTimeoutMs` is the unattended-dialog safety valve: how long the session daemon waits for an answer before settling the dialog with its kind's cancel value (`false` for confirm, `undefined` for select and input). It defaults to `300000` (5 minutes); set it to `0` to wait forever. An extension's own `timeout` option still applies, and the effective deadline is the sooner of the two.
The key is edited directly in the global config file. Restart the session daemon after changing it — for the systemd user service, run `systemctl --user restart pi-web-sessiond`.
### Plugin config
The `plugins` key is only for PI WEB browser plugin enablement/settings on the machine whose config you are editing. It does not install, remove, or update Pi packages; use **Settings → Pi packages** or Pi's package manager for package operations. In a federated setup, **Settings → PI WEB plugins** and **Settings → Pi packages** both target the currently selected machine, and each panel labels where changes will be saved or run.
@@ -321,6 +333,14 @@ Shortcut values are keyed by action id. Values are shortcut strings such as `mod
Prefer Settings → Keyboard for editing shortcuts interactively.
## Prompt completions
The chat composer opens completion menus on three trigger characters:
- `/` at the very start of the draft completes session commands.
- `@` completes file paths: `@` for tracked files, `@ ` (at, then space) or `!@` for all files. Picking one inserts an `@path` reference into the draft, quoted automatically when the path contains spaces.
- `#` completes the models available to the session, filtered case-insensitively as you type (at most 12 entries). Picking one inserts a `#provider/model-id` reference into the draft, which tells agents the request should run on that model — for example as the `model` parameter of `spawn_session`.
## Optional completion tools
File and path `@` completions work without extra tools. If `fzf` is available on the PI WEB server's `PATH`, PI WEB uses it to improve completion filtering/ranking; otherwise it falls back to built-in ranking.
+27
View File
@@ -91,6 +91,7 @@
<strong>On this page</strong>
<a href="#extend">What can be extended</a>
<a href="#packages-vs-plugins">Pi packages, extensions, and plugins</a>
<a href="#pi-extension-dialogs">Pi extension dialogs</a>
<a href="#ask-ai">What to ask AI to build</a>
<a href="#example">Canonical example</a>
<a href="#built-in-plugins">Built-in plugins</a>
@@ -174,6 +175,32 @@
</p>
</section>
<section id="pi-extension-dialogs">
<h2>Pi extension dialogs in PI WEB</h2>
<p>
Pi extensions running under PI WEB's session daemon can ask the user questions with
<code>ctx.ui.confirm()</code>, <code>ctx.ui.select()</code>, and <code>ctx.ui.input()</code>. For these
three methods <code>ctx.hasUI</code> is true in fact: the call renders a dialog card inline in the
session transcript — including from <code>session_start</code> hooks while the session is still starting
and from in-flight <code>tool_call</code> hooks — and resolves with the user's actual answer.
</p>
<p>
Answers travel over a dedicated session-daemon channel, never the prompt queue, so a parked
<code>tool_call</code> hook cannot deadlock the run. Open dialogs survive browser reloads, the first
answer wins across tabs, and unanswered dialogs settle safely: aborting the run or replacing the runtime
resolves them immediately with the kind's cancel value (<code>false</code> for confirm,
<code>undefined</code> for select and input), and the effective deadline — the sooner of the extension's
own <code>timeout</code> and the daemon's <code>extensionDialogsTimeoutMs</code> safety valve (default 5
minutes, <code>0</code> waits forever) — does the same when no one answers. Other
<code>ExtensionUIContext</code> surfaces (widgets, status, editor, <code>custom</code>) remain no-ops
despite <code>hasUI === true</code>.
</p>
<p>
For the full behavior notes and author guidance, read <a href="plugins.md">plugins.md</a>; for the
timeout key, see <a href="config#extension-dialogs">Extension dialogs</a> in the configuration reference.
</p>
</section>
<section id="ask-ai">
<h2>What to ask AI to build</h2>
<p>
+14
View File
@@ -29,6 +29,20 @@ Use **Settings → PI WEB plugins** to enable or disable discovered PI WEB brows
After installing, removing, or updating a Pi package, type `/reload` in each idle PI WEB session on the target machine to refresh ordinary Pi resources such as extensions, skills, prompt templates, themes, and context/system prompt files. Reload the browser page separately for newly discovered or changed PI WEB browser plugins. A provider-registering Pi extension follows a separate daemon-start policy; see [Pi extension provider baseline](https://pi-web.dev/config#pi-extension-provider-baseline).
## Pi extension dialogs in PI WEB
Pi extensions running under PI WEB's session daemon can ask the user questions with `ctx.ui.confirm()`, `ctx.ui.select()`, and `ctx.ui.input()`. PI WEB reports `ctx.hasUI === true`, and for these three dialog methods that is true in fact: the call renders a dialog card inline in the session transcript and the returned Promise resolves with the user's actual answer — a boolean for confirm, the chosen option for select, the typed text for input.
- **Works from hooks, without the prompt queue.** Answers travel over a dedicated session-daemon channel, so a dialog opened inside an in-flight `tool_call` hook parks safely — the agent loop waits for the hook and the run continues with the answer. Consent-gating a tool from a `tool_call` hook is a supported pattern.
- **`session_start` dialogs are reachable.** A dialog opened from a `session_start` hook is answerable while the session is still starting, both when creating a session and when opening an existing one; startup completes once the dialog settles.
- **Survives browser reloads; first answer wins.** Reloading the browser re-renders open dialogs from the session status. With several tabs on the same session, the first answer settles the dialog and the other tabs re-render the settled card.
- **Settled cards stay until dismissed.** An answered or closed dialog leaves its outcome card in the transcript so the user can see what became of it — answers travel to the extension alone, so the card is the only record of the exchange. The card is browser-local: only a browser that saw the dialog open renders it, and switching sessions or reloading drops it.
- **Timeouts.** The extension's own `timeout` option applies, and the daemon adds an unattended-dialog safety valve, `extensionDialogsTimeoutMs` (default 5 minutes, `0` waits forever — see [Extension dialogs](https://pi-web.dev/config#extension-dialogs)). The effective deadline is the sooner of the two. A dialog that closes without an answer resolves with its kind's cancel value: `false` for confirm, `undefined` for select and input.
- **Abort and runtime replacement.** Aborting the current run settles a dialog opened during that run immediately, at abort-request time, with its cancel value. Replacing the session runtime (`/reload`, session disposal) settles any still-open dialog the same way; hooks on the new runtime open fresh dialogs. The extension's own `AbortSignal` is honored: aborting it dismisses the dialog and resolves with the cancel value.
- **Other UI surfaces are still no-ops.** `ExtensionUIContext` methods beyond the three dialogs (widgets, status, editor, `custom`) remain unimplemented under PI WEB even though `hasUI` is `true`; do not rely on `hasUI` alone to detect them.
One browser-local caveat: reloading the browser while a new session is still being created loses the browser-local pending-start row, so the dialog card disappears from view. The daemon-side dialog still settles at its deadline and the session appears in the sidebar once creation completes.
## Trust model
Plugins run as JavaScript in the browser app. Treat them as trusted code:
+113
View File
@@ -0,0 +1,113 @@
# Voice Conversation API (v1)
`/api/v1/voice` is a server-side device API for native iOS and Echo-style clients. It is separate from the browser voice feature. Azure credentials remain on the PI WEB host. Every voice turn automatically includes server-side instructions to produce concise, natural DragonHD-friendly speech without Markdown or visual-only formatting.
## Security and provisioning
Create a device token on the PI WEB host:
```sh
pi-web voice-token create \
--project project-id --workspace workspace-id \
--model openai-codex/gpt-5.6-terra --thinking high
```
Each restriction flag may be repeated. Omitting a category grants `*` for that category. A restricted model or thinking scope requires the client to explicitly select an allowed value; it cannot inherit a server default. Project and workspace scopes are both enforced. Token records contain a SHA-256 token hash only and are persisted at mode `0600` in `~/.config/pi-web/voice-api.json`; the plaintext (`pwv1_...`) is printed only once.
```sh
pi-web voice-token list
pi-web voice-token revoke <token-id>
```
Use `Authorization: Bearer pwv1_...` on every HTTP and WebSocket connection. The API requires HTTPS, except loopback for local development/testing. A conversation is owned by the creating token: other valid device tokens receive `404` when they try to inspect, delete, or attach it. A client may select only a registered workspace id, never a filesystem path.
## HTTP
`GET /api/v1/voice/targets` returns only the caller's authorized registered workspaces plus effective scopes. Use its `workspaces[].id` to choose a workspace; paths are returned for display but are never accepted as an API input.
`GET /api/v1/voice/conversations?workspaceId=<workspace-id>` lists prior Pi sessions rooted at that authorized workspace. This lets a device show a conversation picker without learning about sessions outside its workspace scope.
`POST /api/v1/voice/conversations`
```json
{
"workspaceId": "registered-workspace-id",
"model": "openai-codex/gpt-5.6-terra",
"thinking": "high",
"context": "You are helping with the home-automation project."
}
```
For an existing directory beneath the device-safe root `/home/hope/workspaces` that is not yet registered as a PI WEB workspace, use `POST /api/v1/voice/conversations/path` with `path` instead of `workspaceId`. The path must already exist, resolve beneath that root, and is validated before a session starts.
The response is `201` and includes the opaque conversation id, session id, selected workspace, and `input-ready` status. `GET /api/v1/voice/conversations/:id/models` lists the models currently available to that conversation's Pi session, filtered by the device token's model scope. `GET` and `DELETE /api/v1/voice/conversations/:id` inspect and close the API conversation handle.
To resume a listed session, create a new token-owned voice handle:
```json
POST /api/v1/voice/conversations/resume
{
"workspaceId": "registered-workspace-id",
"sessionId": "previous-pi-session-id"
}
```
The session must be in the selected workspace and within the caller's token scope.
## WebSocket wire protocol
Connect to `wss://host/api/v1/voice/stream` with the Bearer header. The server first sends:
```json
{
"type":"hello",
"protocol":"pi-web.voice.v1",
"pcm":{"input":"s16le/16000/mono","output":"s16le/24000/mono"},
"binary":{"version":1,"headerBytes":8,"kind":"audio"}
}
```
Attach an owned conversation and wait for `input.ready` before sending a turn:
```json
{"type":"attach","conversationId":"..."}
```
### Text fallback
Send exactly one JSON input while `input-ready`:
```json
{"type":"input.text","text":"Turn on the kitchen lights."}
```
### Audio input
For speech input, send binary WebSocket frames containing raw signed little-endian 16-bit, 16 kHz, mono PCM (no WAV header). Frames must be nonempty, <=64 KiB, aligned to two bytes, and an utterance is limited to two minutes. Finish with:
```json
{"type":"input.end"}
```
Azure push-stream recognition emits `transcript.partial` and `transcript.final` JSON events. The final transcript is submitted to Pi. This Voice API never returns an Azure token or key to a device.
### Turn output and lifecycle
For either input form, the server emits `agent.working`, then `agent.accepted` after Pi accepted the prompt. If work is still in progress after two seconds and nothing has been spoken yet (see below), it sends one `agent.progress` event with a short spoken status clip, picked pseudo-randomly from a small set of natural filler phrases (“One sec.”, “Let me take a look.”, “Hmm, checking now.”, “Working on it.”, “Give me a moment.”, “Let me look into that.”). The server never repeats the same phrase on two consecutive turns. Ordinary screen-only `agent.working` heartbeats continue every 15 seconds regardless.
During a long turn, Pi may emit several assistant messages between tool calls before its final answer — each one ends with Pi's `message.end` event. Whenever a completed message includes a tool call (i.e., it is not the turn's final answer), the server also speaks it: it sends `{"type":"agent.progress","text":"<message text>","audio":true}``text` is always the message's full, untruncated text — immediately followed by that clip's binary audio frames, unless every word of that text was already spoken live as answer sentences (see below), in which case the clip is skipped entirely, or only its unspoken remainder is synthesized if part of it was spoken live and part was not. `agent.progress` may therefore occur multiple times per turn, always in the order the messages completed, and each is optionally followed by its own clip's binary frames. For speech only, a message longer than about 300 characters is truncated at a sentence boundary where possible (the JSON `text` field is never truncated). Progress clips are sent one at a time — a clip's audio always finishes sending before the next `agent.progress` frame is sent — and a turn's total spoken progress narration is capped at roughly 60 seconds of audio or 8 clips, whichever comes first; the fallback phrase counts toward this same cap. Once the turn's answer has audibly begun (the first `answer.sentence` has been sent — see below), a brand-new message that had no live narration of its own is left quiet rather than starting fresh progress narration that would talk over the answer; a message that was already mid-narration when a tool call appeared in it still gets its own unspoken leftover finished so it isn't cut off abruptly. `agent.progress` frames, spoken or not, always stop before `assistant.final`.
While the final answer streams in as Pi generates it, the server does not wait for it to fully finish before speaking it: as each complete sentence of the in-progress message accumulates (split on `.`/`!`/`?` — plus a closing quote or bracket — followed by whitespace; a decimal like “3.5” never triggers a false split, since the period is not followed by whitespace), the server sends `{"type":"answer.sentence","text":"<sentence>"}` immediately followed by that sentence's own binary audio frames, serialized through the same ordered chain as `agent.progress` clips so nothing interleaves or reorders on the single audio output. Markdown-decoration-only fragments (e.g. `---`, `` ``` ``, `**`) are skipped silently. Because a message might still turn out to include a tool call (making it narration, not the final answer), every message is optimistically streamed this way until either a tool call is detected in it or it ends; once a tool call appears, sentence-streaming for that message stops (already-spoken sentences remain spoken — that's fine acoustically) and any unspoken remainder is handled by the `agent.progress` mechanism above instead. A message's trailing fragment with no terminal punctuation yet is held back until the message ends, then flushed as one final `answer.sentence`. Spoken answer audio is capped at roughly 120 seconds or 40 sentences per turn, whichever comes first; once agent.settled fires, `assistant.final` (the full, untruncated answer text) is always sent next, and any part of the answer that was never spoken live because a cap was hit is synthesized once, as ordinary audio frames, between `assistant.final` and `audio.end` — a turn where every sentence streamed live sends zero audio frames in that window. `audio.end` always ends the turn's audio either way.
While it waits for Pi's native `agent.settled` event (not merely `agent.end`), it repeats the existing `agent.working` frame every 15 seconds; those progress frames stop before `assistant.final` and never overlap synthesized audio. The wait has a two-minute turn timeout. `assistant.delta` is streamed while Pi answers; `assistant.final` contains only assistant text, never STT transcript.
Azure synthesis output is signed little-endian 16-bit, 24 kHz, mono PCM. Every binary server frame has this eight-byte header followed by PCM:
| Bytes | Meaning |
|---|---|
| 0 | protocol version: `1` |
| 1 | kind: `1` (audio) |
| 23 | reserved: `0` (big endian) |
| 47 | monotonically increasing frame sequence (uint32 big endian) |
The server then emits `{"type":"audio.end"}` and a fresh `input.ready`. Send `{"type":"close"}` or close the WebSocket to end; disconnecting closes STT/event resources and clears the handle's working state.
+248 -63
View File
@@ -1,12 +1,12 @@
{
"name": "@jmfederico/pi-web",
"version": "1.202607.2",
"version": "1.202607.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@jmfederico/pi-web",
"version": "1.202607.2",
"version": "1.202607.3",
"license": "MIT",
"dependencies": {
"@codemirror/commands": "^6.10.4",
@@ -24,6 +24,8 @@
"@codemirror/view": "^6.43.6",
"@fastify/compress": "^9.0.0",
"@fastify/static": "^9.3.0",
"@fastify/swagger": "^9.8.1",
"@fastify/swagger-ui": "^5.2.6",
"@fastify/websocket": "^11.3.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
@@ -31,6 +33,7 @@
"fastify": "^5.10.0",
"lit": "^3.3.3",
"marked": "^18.0.6",
"microsoft-cognitiveservices-speech-sdk": "^1.51.0",
"node-pty": "^1.1.0",
"typebox": "1.3.6",
"ws": "^8.21.0"
@@ -52,6 +55,7 @@
"globals": "^17.7.0",
"happy-dom": "^20.11.1",
"knip": "^6.25.0",
"openapi-types": "^12.1.3",
"tsx": "^4.23.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.63.0",
@@ -547,6 +551,46 @@
"node": ">=18.0.0"
}
},
"node_modules/@azure/abort-controller": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.2.0.tgz",
"integrity": "sha512-fNAjWnA/nZ2jz31kxR/AqRaUT8ewHBw/WuBIosK0moMy1C9e5ValbDfFdIxJzVOOYaYkV/b2F1S4H/aHiqfVQg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@azure/core-auth": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.11.0.tgz",
"integrity": "sha512-IUZydyTUkDnYdstOW9pFOOUQlBjAepK5teihDE3x6yxsPJs/hsAaaYpeGxdxrgtOiJbBKSjKW7MDk7AEhb4LRg==",
"license": "MIT",
"dependencies": {
"@azure/abort-controller": "^2.1.2",
"@azure/core-util": "^1.13.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@azure/core-util": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.14.0.tgz",
"integrity": "sha512-9n2pWK61veAuN0V20t9lOuoV4CFMdyAZ1ygZzvBGk/pBBJRib/PjL9PLXa/aI2CcPpyHfqVsxxqLCYl6uZlfDw==",
"license": "MIT",
"dependencies": {
"@azure/abort-controller": "^2.1.2",
"@typespec/ts-http-runtime": "^0.3.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
@@ -3866,6 +3910,68 @@
"glob": "^13.0.0"
}
},
"node_modules/@fastify/swagger": {
"version": "9.8.1",
"resolved": "https://registry.npmjs.org/@fastify/swagger/-/swagger-9.8.1.tgz",
"integrity": "sha512-VpHMnqZTY8iBZYJE8WWkbKPrXIYWy2rDfIf5qLr6DzZSpQYZ+KxQVcJFiq/AMlvNwI4gCBd66++iUlxXXGT0IQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"fastify-plugin": "^6.0.0",
"json-schema-resolver": "^3.0.0",
"openapi-types": "^12.1.3",
"rfdc": "^1.3.1",
"yaml": "^2.4.2"
}
},
"node_modules/@fastify/swagger-ui": {
"version": "5.2.6",
"resolved": "https://registry.npmjs.org/@fastify/swagger-ui/-/swagger-ui-5.2.6.tgz",
"integrity": "sha512-OMnms0O5s9wb6wis/K5nlrAMLsgUbr1GA8uphM41IasWe3AFdgxz6r/3bA9HTxlDNUYc2FGGKeqMp3ntxmSiNA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/static": "^9.1.2",
"fastify-plugin": "^5.0.0",
"openapi-types": "^12.1.3",
"rfdc": "^1.3.1",
"yaml": "^2.4.1"
}
},
"node_modules/@fastify/swagger-ui/node_modules/fastify-plugin": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz",
"integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/@fastify/websocket": {
"version": "11.3.0",
"resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.3.0.tgz",
@@ -4445,9 +4551,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4465,9 +4568,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4485,9 +4585,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4505,9 +4602,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4525,9 +4619,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4545,9 +4636,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4565,9 +4653,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4585,9 +4670,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5153,9 +5235,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5173,9 +5252,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5193,9 +5269,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5213,9 +5286,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5233,9 +5303,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5253,9 +5320,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5550,6 +5614,12 @@
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/@types/webrtc": {
"version": "0.0.37",
"resolved": "https://registry.npmjs.org/@types/webrtc/-/webrtc-0.0.37.tgz",
"integrity": "sha512-JGAJC/ZZDhcrrmepU4sPLQLIOIAgs5oIK+Ieq90K8fdaNMhfdfqmYatJdgif1NDQtvrSlTOGJDUYHIDunuufOg==",
"license": "MIT"
},
"node_modules/@types/whatwg-mimetype": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
@@ -5787,6 +5857,20 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typespec/ts-http-runtime": {
"version": "0.3.8",
"resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.8.tgz",
"integrity": "sha512-bLMpVcWZNzq6lYOybwFwOAR1IXKcHnhUNqYeHjl1bET/qE3jFPFH+p8Wrh3rU4xwdnifPxmKNESBYnvnmc75aA==",
"license": "MIT",
"dependencies": {
"http-proxy-agent": "^7.0.0",
"https-proxy-agent": "^7.0.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@vitest/expect": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
@@ -5960,7 +6044,6 @@
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
@@ -6127,6 +6210,17 @@
],
"license": "MIT"
},
"node_modules/bent": {
"version": "7.3.12",
"resolved": "https://registry.npmjs.org/bent/-/bent-7.3.12.tgz",
"integrity": "sha512-T3yrKnVGB63zRuoco/7Ybl7BwwGZR0lceoVG5XmQyMIH9s19SV5m+a8qam4if0zQuAmOQTyPTPmsQBdAorGK3w==",
"license": "Apache-2.0",
"dependencies": {
"bytesish": "^0.4.1",
"caseless": "~0.12.0",
"is-stream": "^2.0.0"
}
},
"node_modules/better-path-resolve": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/better-path-resolve/-/better-path-resolve-1.0.0.tgz",
@@ -6232,6 +6326,18 @@
"node": ">=4.0"
}
},
"node_modules/bytesish": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/bytesish/-/bytesish-0.4.4.tgz",
"integrity": "sha512-i4uu6M4zuMUiyfZN4RU2+i9+peJh//pXhd9x1oSe1LBkZ3LEbCoygu8W0bXTukU1Jme2txKuotpCZRaC3FLxcQ==",
"license": "(Apache-2.0 AND MIT)"
},
"node_modules/caseless": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
"integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==",
"license": "Apache-2.0"
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@@ -6323,7 +6429,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -7451,7 +7556,6 @@
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.0",
@@ -7465,7 +7569,6 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
@@ -7590,6 +7693,18 @@
"node": ">=0.12.0"
}
},
"node_modules/is-stream": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-subdir": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/is-subdir/-/is-subdir-1.2.0.tgz",
@@ -7695,6 +7810,23 @@
"dequal": "^2.0.3"
}
},
"node_modules/json-schema-resolver": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/json-schema-resolver/-/json-schema-resolver-3.0.0.tgz",
"integrity": "sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==",
"license": "MIT",
"dependencies": {
"debug": "^4.1.1",
"fast-uri": "^3.0.5",
"rfdc": "^1.1.4"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/Eomm/json-schema-resolver?sponsor=1"
}
},
"node_modules/json-schema-to-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
@@ -7999,9 +8131,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8023,9 +8152,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8047,9 +8173,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8071,9 +8194,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8255,6 +8375,55 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/microsoft-cognitiveservices-speech-sdk": {
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/microsoft-cognitiveservices-speech-sdk/-/microsoft-cognitiveservices-speech-sdk-1.51.0.tgz",
"integrity": "sha512-BLLovv5PegOr5Lp52h4CSgL2c/ViFAh9LoU49ILNPyb/Z0giGI7nPV3xNLcmTtHLT+kfYNRGUIA62vORLzP8TA==",
"license": "MIT",
"dependencies": {
"@azure/core-auth": "^1.9.0",
"@types/webrtc": "^0.0.37",
"agent-base": "^6.0.1",
"bent": "^7.3.12",
"https-proxy-agent": "^4.0.0",
"uuid": "^11.1.1",
"ws": "^8.21.0"
}
},
"node_modules/microsoft-cognitiveservices-speech-sdk/node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"license": "MIT",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/microsoft-cognitiveservices-speech-sdk/node_modules/https-proxy-agent": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-4.0.0.tgz",
"integrity": "sha512-zoDhWrkR3of1l9QAL8/scJZyLu8j/gBkcwcaQOZh7Gyh/+uJQzGVETdgT30akuwkpL8HTRfssqI3BZuV18teDg==",
"license": "MIT",
"dependencies": {
"agent-base": "5",
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/microsoft-cognitiveservices-speech-sdk/node_modules/https-proxy-agent/node_modules/agent-base": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-5.1.1.tgz",
"integrity": "sha512-TMeqbNl2fMW0nMjTEPOwe3J/PRFP4vqeoNuQMG0HlMrtm5QxKqdvAkZ1pRBQ/ulIyDD5Yq0nJ7YbdD8ey0TO3g==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/mime": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
@@ -8314,7 +8483,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
@@ -8453,6 +8621,12 @@
}
}
},
"node_modules/openapi-types": {
"version": "12.1.3",
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
"integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==",
"license": "MIT"
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -9632,7 +9806,6 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/tsx": {
@@ -9754,6 +9927,19 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/uuid": {
"version": "11.1.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz",
"integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/vite": {
"version": "8.1.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz",
@@ -10041,7 +10227,6 @@
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"dev": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@jmfederico/pi-web",
"version": "1.202607.2",
"version": "1.202607.3",
"description": "Web UI for persistent Pi Coding Agent sessions in real workspaces.",
"license": "MIT",
"author": "Federico Jaramillo Martinez",
@@ -70,6 +70,8 @@
"@codemirror/view": "^6.43.6",
"@fastify/compress": "^9.0.0",
"@fastify/static": "^9.3.0",
"@fastify/swagger": "^9.8.1",
"@fastify/swagger-ui": "^5.2.6",
"@fastify/websocket": "^11.3.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
@@ -77,6 +79,7 @@
"fastify": "^5.10.0",
"lit": "^3.3.3",
"marked": "^18.0.6",
"microsoft-cognitiveservices-speech-sdk": "^1.51.0",
"node-pty": "^1.1.0",
"typebox": "1.3.6",
"ws": "^8.21.0"
@@ -93,6 +96,7 @@
"globals": "^17.7.0",
"happy-dom": "^20.11.1",
"knip": "^6.25.0",
"openapi-types": "^12.1.3",
"tsx": "^4.23.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.63.0",
@@ -109,7 +109,7 @@ describe("multiple relays", () => {
const select = picker(panel);
if (select === null) throw new Error("relay picker missing");
select.value = `${RELAYS_ROOT}/older`;
select.dispatchEvent(new Event("change"));
select.dispatchEvent(new Event("change", { bubbles: true })); // Real change events bubble; the panel listens at the region container.
await flushAsync();
expect(fake.listFiles).toHaveBeenCalledWith(`${RELAYS_ROOT}/older`);
@@ -167,6 +167,77 @@ describe("document tabs", () => {
expect(children.map((child) => child.textContent)).toEqual(["status.md", "log.md", "notes.md"]);
});
it("keeps the tab strip mounted with its scroll and focus when switching documents", async () => {
const fake = workspaceFilesFake();
fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]);
fake.addDirectory(`${RELAYS_ROOT}/relay`, [
relayDocument("relay", "notes.md"),
relayDocument("relay", "log.md"),
relayDocument("relay", "status.md"),
]);
fake.addDocument(`${RELAYS_ROOT}/relay/status.md`, "status body");
fake.addDocument(`${RELAYS_ROOT}/relay/log.md`, "log body");
const panel = await mountPanel(panelContext(fake));
const strip = tabStrip(panel);
strip.scrollLeft = 120;
const logTab = tabNamed(panel, "log.md");
logTab.click();
await flushAsync();
// Switching documents only re-renders the viewer: the strip element and
// its buttons stay mounted, so scroll position and button identity survive.
expect(documentText(panel)).toBe("log body");
expect(tabStrip(panel)).toBe(strip);
expect(tabStrip(panel).scrollLeft).toBe(120);
expect(tabNamed(panel, "log.md")).toBe(logTab);
expect(activeTab(panel)?.textContent).toBe("log.md");
});
it("scrolls the viewer back to the top when switching documents", async () => {
const fake = workspaceFilesFake();
fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]);
fake.addDirectory(`${RELAYS_ROOT}/relay`, [relayDocument("relay", "log.md"), relayDocument("relay", "status.md")]);
fake.addDocument(`${RELAYS_ROOT}/relay/status.md`, "status body");
fake.addDocument(`${RELAYS_ROOT}/relay/log.md`, "log body");
const panel = await mountPanel(panelContext(fake));
const viewer = shadow(panel).querySelector("section.viewer");
if (!(viewer instanceof HTMLElement)) throw new Error("viewer missing");
viewer.scrollTop = 80;
tabNamed(panel, "log.md").click();
await flushAsync();
expect(documentText(panel)).toBe("log body");
expect(viewer.scrollTop).toBe(0);
});
it("starts a different relay's tab strip at the left edge", async () => {
const fake = workspaceFilesFake();
fake.addDirectory(RELAYS_ROOT, [
relayDirectory("alpha", "2026-01-01T00:00:00.000Z"),
relayDirectory("beta", "2026-02-01T00:00:00.000Z"),
]);
fake.addDirectory(`${RELAYS_ROOT}/alpha`, [relayDocument("alpha", "status.md")]);
fake.addDirectory(`${RELAYS_ROOT}/beta`, [relayDocument("beta", "status.md"), relayDocument("beta", "log.md")]);
fake.addDocument(`${RELAYS_ROOT}/beta/status.md`, "beta status");
fake.addDocument(`${RELAYS_ROOT}/alpha/status.md`, "alpha status");
const panel = await mountPanel(panelContext(fake));
tabStrip(panel).scrollLeft = 120;
const select = picker(panel);
if (select === null) throw new Error("relay picker missing");
select.value = `${RELAYS_ROOT}/alpha`;
select.dispatchEvent(new Event("change", { bubbles: true })); // Real change events bubble; the panel listens at the region container.
await flushAsync();
expect(documentText(panel)).toBe("alpha status");
expect(tabStrip(panel).scrollLeft).toBe(0);
});
it("shows a truncation notice when the open document is truncated", async () => {
const fake = workspaceFilesFake();
fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]);
@@ -308,7 +379,7 @@ describe("refresh and context changes", () => {
const select = picker(panel);
if (select === null) throw new Error("relay picker missing");
select.value = `${RELAYS_ROOT}/alpha`;
select.dispatchEvent(new Event("change"));
select.dispatchEvent(new Event("change", { bubbles: true })); // Real change events bubble; the panel listens at the region container.
await flushAsync();
tabNamed(panel, "log.md").click();
await flushAsync();
@@ -463,6 +534,12 @@ function refreshButton(panel: RelaysPanelTestElement): HTMLElement {
return button;
}
function tabStrip(panel: RelaysPanelTestElement): HTMLElement {
const strip = shadow(panel).querySelector("nav.document-tabs");
if (!(strip instanceof HTMLElement)) throw new Error("tab strip missing");
return strip;
}
function tabNames(panel: RelaysPanelTestElement): string[] {
return [...shadow(panel).querySelectorAll("button[data-document-path]")].map((tab) => tab.textContent);
}
+110 -46
View File
@@ -29,6 +29,12 @@ interface RelaySelection {
* sanitized HTML and everything else as preformatted text. All async loads
* flow through scanToken so stale responses for a previous workspace or
* selection never overwrite newer state.
*
* Rendering is region-scoped: the toolbar, tab strip, and viewer are
* persistent elements built once, and each async stage re-renders only its
* own region. Clicking a tab never rebuilds the strip — it toggles the
* active marker in place and re-renders the viewer — so the strip's
* horizontal scroll position and keyboard focus survive document switches.
*/
class PiWebRelaysPanel extends HTMLElement {
private contextValue: WorkspacePanelContext | undefined;
@@ -39,36 +45,64 @@ class PiWebRelaysPanel extends HTMLElement {
private documentContent: RelayDocumentContent | undefined;
private scanToken = 0;
private readonly root: ShadowRoot;
private readonly toolbar: HTMLElement;
private readonly tabStrip: HTMLElement;
private readonly viewer: HTMLElement;
constructor() {
super();
this.root = this.attachShadow({ mode: "open" });
this.root.innerHTML = `
${relaysStyles()}
<section class="toolbar" hidden></section>
<nav class="document-tabs" aria-label="Relay documents" hidden></nav>
<section class="viewer"><div class="empty">Select a workspace.</div></section>
`;
this.toolbar = requiredRegion(this.root, ".toolbar");
this.tabStrip = requiredRegion(this.root, "nav.document-tabs");
this.viewer = requiredRegion(this.root, ".viewer");
// Listeners bind once against the persistent regions and delegate to
// whichever controls the latest region render produced.
this.toolbar.addEventListener("click", (event) => {
const button = event.target instanceof Element ? event.target.closest("button[data-refresh]") : null;
if (button !== null) this.refresh();
});
this.toolbar.addEventListener("change", (event) => {
const picker = event.target;
if (!(picker instanceof HTMLSelectElement) || !picker.matches("select[data-relay-picker]")) return;
const context = this.contextValue;
if (context !== undefined) void this.openRelay(context, picker.value);
});
this.tabStrip.addEventListener("click", (event) => {
const tab = event.target instanceof Element ? event.target.closest("button[data-document-path]") : null;
if (tab === null) return;
const documentPath = tab.getAttribute("data-document-path");
const context = this.contextValue;
if (context !== undefined && documentPath !== null) void this.openDocument(context, documentPath);
});
}
set context(value: WorkspacePanelContext | undefined) {
const previousKey = this.contextValue === undefined ? undefined : contextKey(this.contextValue);
const nextKey = value === undefined ? undefined : contextKey(value);
this.contextValue = value;
// Parent app updates should not rescan or rebuild this shadow DOM for the
// Parent app updates should not rescan or re-render this panel for the
// same workspace (mirrors the workspace-tasks panel).
if (previousKey === nextKey) return;
if (value === undefined) {
this.resetScanState();
this.render();
this.renderAll();
return;
}
void this.scan(value, {});
}
connectedCallback(): void {
this.render();
}
/** Rescan relays, then reload the selected relay's documents and the open document. */
private async scan(context: WorkspacePanelContext, selection: RelaySelection): Promise<void> {
const token = ++this.scanToken;
this.resetScanState();
this.render();
this.renderAll();
const listing = await listWorkspaceRelays(context.files);
if (!this.isCurrentScan(context, token)) return;
@@ -80,8 +114,9 @@ class PiWebRelaysPanel extends HTMLElement {
? listing.relays.find((candidate) => candidate.path === selection.relayPath) ?? listing.relays[0]
: undefined;
this.selectedRelayPath = relay?.path;
this.renderToolbar();
if (relay === undefined) {
this.render();
this.renderViewer();
return;
}
await this.loadDocuments(context, token, relay.path, selection.documentPath);
@@ -96,6 +131,11 @@ class PiWebRelaysPanel extends HTMLElement {
private async openDocument(context: WorkspacePanelContext, documentPath: string): Promise<void> {
const token = ++this.scanToken;
this.selectedDocumentPath = documentPath;
// The tab set is unchanged: toggle the active marker on the mounted
// buttons instead of rebuilding the strip, so its scroll position and
// focus stay put. A different document starts reading from the top.
this.updateActiveTab();
this.viewer.scrollTop = 0;
await this.loadDocumentContent(context, token, documentPath);
}
@@ -103,7 +143,8 @@ class PiWebRelaysPanel extends HTMLElement {
this.documents = undefined;
this.selectedDocumentPath = undefined;
this.documentContent = undefined;
this.render();
this.renderTabs();
this.renderViewer();
const documents = await listRelayDocuments(context.files, relayPath);
if (!this.isCurrentScan(context, token)) return;
@@ -113,8 +154,9 @@ class PiWebRelaysPanel extends HTMLElement {
? documents.documents.find((candidate) => candidate.path === preferredDocumentPath) ?? defaultRelayDocument(documents.documents)
: undefined;
this.selectedDocumentPath = document?.path;
this.renderTabs();
if (document === undefined) {
this.render();
this.renderViewer();
return;
}
await this.loadDocumentContent(context, token, document.path);
@@ -122,12 +164,12 @@ class PiWebRelaysPanel extends HTMLElement {
private async loadDocumentContent(context: WorkspacePanelContext, token: number, documentPath: string): Promise<void> {
this.documentContent = undefined;
this.render();
this.renderViewer();
const content = await readRelayDocument(context.files, documentPath);
if (!this.isCurrentScan(context, token)) return;
this.documentContent = content;
this.render();
this.renderViewer();
}
private refresh(): void {
@@ -148,38 +190,26 @@ class PiWebRelaysPanel extends HTMLElement {
return token === this.scanToken && this.contextValue !== undefined && contextKey(this.contextValue) === contextKey(context);
}
private render(): void {
const context = this.contextValue;
if (context === undefined) {
this.root.innerHTML = `${relaysStyles()}<section class="empty">Select a workspace.</section>`;
private renderAll(): void {
this.renderToolbar();
this.renderTabs();
this.renderViewer();
}
private renderToolbar(): void {
if (this.contextValue === undefined) {
this.toolbar.hidden = true;
this.toolbar.replaceChildren();
return;
}
this.root.innerHTML = `
${relaysStyles()}
<section class="toolbar">
<strong>Relays</strong>
<span class="toolbar-actions">
${this.renderRelayPicker()}
<button class="icon-button" data-refresh aria-label="Refresh" title="Refresh">${refreshIconSvg()}</button>
</span>
</section>
${this.renderDocumentTabs()}
<section class="viewer">${this.renderViewer()}</section>
this.toolbar.hidden = false;
this.toolbar.innerHTML = `
<strong>Relays</strong>
<span class="toolbar-actions">
${this.renderRelayPicker()}
<button class="icon-button" data-refresh aria-label="Refresh" title="Refresh">${refreshIconSvg()}</button>
</span>
`;
this.root.querySelector("button[data-refresh]")?.addEventListener("click", () => {
this.refresh();
});
this.root.querySelector("select[data-relay-picker]")?.addEventListener("change", (event) => {
const picker = event.target;
if (picker instanceof HTMLSelectElement) void this.openRelay(context, picker.value);
});
for (const tab of this.root.querySelectorAll("button[data-document-path]")) {
tab.addEventListener("click", () => {
const documentPath = tab.getAttribute("data-document-path");
if (documentPath !== null) void this.openDocument(context, documentPath);
});
}
}
private renderRelayPicker(): string {
@@ -197,17 +227,43 @@ class PiWebRelaysPanel extends HTMLElement {
return `<select data-relay-picker aria-label="Relay">${options}</select>`;
}
private renderDocumentTabs(): string {
private renderTabs(): void {
const documents = this.documents;
if (documents?.kind !== "loaded" || documents.documents.length === 0) return "";
const tabs = documents.documents.map((document) => {
if (documents?.kind !== "loaded" || documents.documents.length === 0) {
this.tabStrip.hidden = true;
// A new tab set starts at the left edge, not at the previous set's offset.
this.tabStrip.replaceChildren();
this.tabStrip.scrollLeft = 0;
return;
}
this.tabStrip.hidden = false;
// The strip element itself persists across re-renders, so replacing its
// buttons keeps the container's horizontal scroll position.
this.tabStrip.innerHTML = documents.documents.map((document) => {
const active = document.path === this.selectedDocumentPath;
return `<button class="document-tab${active ? " active" : ""}" data-document-path="${escapeAttr(document.path)}"${active ? ' aria-current="true"' : ""}>${escapeHtml(document.name)}</button>`;
}).join("");
return `<nav class="document-tabs" aria-label="Relay documents">${tabs}</nav>`;
}
private renderViewer(): string {
/** Move the active marker between the mounted tab buttons without rebuilding them. */
private updateActiveTab(): void {
for (const tab of this.tabStrip.querySelectorAll("button[data-document-path]")) {
const active = tab.getAttribute("data-document-path") === this.selectedDocumentPath;
tab.classList.toggle("active", active);
if (active) tab.setAttribute("aria-current", "true");
else tab.removeAttribute("aria-current");
}
}
private renderViewer(): void {
if (this.contextValue === undefined) {
this.viewer.innerHTML = `<div class="empty">Select a workspace.</div>`;
return;
}
this.viewer.innerHTML = this.renderViewerContent();
}
private renderViewerContent(): string {
const listing = this.listing;
if (listing === undefined) return `<p class="muted">Scanning ${escapeHtml(RELAYS_ROOT)}…</p>`;
if (listing.kind === "unavailable") return renderErrorState("Could not scan workspace relays.", listing.detail);
@@ -255,6 +311,13 @@ class PiWebRelaysPanel extends HTMLElement {
}
}
/** Shell regions come from a literal template; absence means the template broke. */
function requiredRegion(root: ShadowRoot, selector: string): HTMLElement {
const element = root.querySelector(selector);
if (!(element instanceof HTMLElement)) throw new Error(`relays panel shell is missing ${selector}`);
return element;
}
/** Reload glyph matching the app's own refresh control (AppRefreshControl). */
function refreshIconSvg(): string {
return `
@@ -297,6 +360,7 @@ function relaysStyles(): string {
the viewer's huge content basis starves them down to a sliver once a
tall document renders). The viewer absorbs all shrinking instead. */
.toolbar { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--pi-border-muted); }
.toolbar[hidden], .document-tabs[hidden] { display: none; }
.toolbar-actions { display: inline-flex; align-items: center; flex-wrap: nowrap; justify-content: flex-end; gap: 8px; min-width: 0; }
.relay-name { min-width: 0; color: var(--pi-text-secondary); overflow-wrap: anywhere; }
/* Bottom padding (not viewer margin) so the gap below the tabs persists
+4
View File
@@ -26,8 +26,12 @@ describe("production build contents", () => {
try {
const fixtureDist = join(fixtureRoot, "dist", "server");
await mkdir(fixtureDist, { recursive: true });
// npm pack runs the package's prepare lifecycle even with --ignore-scripts
// on the npm version used in CI, so include its harmless fixture script.
await mkdir(join(fixtureRoot, "scripts"), { recursive: true });
await Promise.all([
copyFile(join(repoRoot, "package.json"), join(fixtureRoot, "package.json")),
copyFile(join(repoRoot, "scripts", "install-git-hooks.mjs"), join(fixtureRoot, "scripts", "install-git-hooks.mjs")),
writeFile(join(fixtureDist, "app.js"), "export {};\n", "utf8"),
writeFile(join(fixtureDist, "app.testSupport.js"), "export {};\n", "utf8"),
writeFile(join(fixtureDist, "app.testSupport.js.map"), "{}\n", "utf8"),
+48
View File
@@ -41,6 +41,7 @@ import {
nativeServicePrerequisiteShellCheck,
} from "./nativeServices/serviceProbe.js";
import { renderLaunchdPlist, renderSystemdUnit } from "./nativeServices/serviceRendering.js";
import { createVoiceToken, listVoiceTokens, revokeVoiceToken } from "./server/voiceApi.js";
const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web";
@@ -1060,6 +1061,9 @@ Usage:
pi-web start|stop|restart|status|logs
pi-web doctor
pi-web version
pi-web voice-token create [--model provider/model] [--thinking level] [--project project-id] [--workspace workspace-id]
pi-web voice-token list
pi-web voice-token revoke <token-id>
Recommended install:
npm install -g @jmfederico/pi-web --allow-scripts=node-pty
@@ -1078,11 +1082,55 @@ async function main(): Promise<void> {
else if (command === "logs") logs();
else if (command === "doctor") await doctor();
else if (command === "version") await printPiWebVersionReport();
else if (command === "voice-token") voiceToken(args);
else if (command === "--version" || command === "-v") console.log(packageVersion());
else if (command === "help" || command === "--help" || command === "-h") help();
else throw new Error(`Unknown command: ${command}`);
}
function voiceToken(args: string[]): void {
const [action, ...rest] = args;
if (action === "list") { console.log(JSON.stringify(listVoiceTokens(), null, 2)); return; }
if (action === "revoke") {
const id = rest[0];
if (id === undefined || !revokeVoiceToken(id)) throw new Error("Voice token was not found or is already revoked");
console.log(`Revoked voice token ${id}`);
return;
}
if (action === "create") {
const models: string[] = [];
const thinking: string[] = [];
const projects: string[] = [];
const workspaces: string[] = [];
for (let index = 0; index < rest.length; index += 1) {
const flag = rest[index];
const value = rest[index + 1];
if (
(flag !== "--model" &&
flag !== "--thinking" &&
flag !== "--project" &&
flag !== "--workspace") ||
value === undefined
)
throw new Error("Usage: pi-web voice-token create [--model provider/model] [--thinking level] [--project project-id] [--workspace workspace-id]");
if (flag === "--model") models.push(value);
else if (flag === "--thinking") thinking.push(value);
else if (flag === "--project") projects.push(value);
else workspaces.push(value);
index += 1;
}
const created = createVoiceToken({
...(models.length === 0 ? {} : { models }),
...(thinking.length === 0 ? {} : { thinking }),
...(projects.length === 0 ? {} : { projects }),
...(workspaces.length === 0 ? {} : { workspaces }),
});
console.log(`Voice token ${created.id} (shown once): ${created.token}`);
return;
}
throw new Error("Usage: pi-web voice-token create|list|revoke");
}
export function isCliEntrypoint(entrypoint: string | undefined = process.argv[1], modulePath: string = fileURLToPath(import.meta.url)): boolean {
if (entrypoint === undefined) return false;
if (entrypoint === modulePath) return true;
+2 -2
View File
@@ -1,5 +1,5 @@
export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piPackagesApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
export { activityApi, api, azureSpeechApi, configApi, filesApi, gitApi, machinesApi, piPackagesApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
export { DEFAULT_WORKSPACE_UPLOADS_FOLDER, effectiveWorkspaceUploadFolder, uploadWorkspaceFile, uploadWorkspaceFiles, workspaceEffectiveUploadFolder, workspaceUploadPath, WorkspaceUploadBatchError, WorkspaceUploadCancelledError } from "./api/workspaceUploads";
export type { UploadWorkspaceFileOptions, UploadWorkspaceFilesOptions, WorkspaceFileUploadProgress, WorkspaceUploadBatchFileProgress, WorkspaceUploadBatchProgress, WorkspaceUploadFileFailure, WorkspaceUploadFileInput, WorkspaceUploadFolderConfig, WorkspaceUploadTask, WorkspaceUploadXhr, WorkspaceUploadXhrFactory } from "./api/workspaceUploads";
export type { ActiveAgentProfileDescriptor, ArchiveSessionsResponse, AskUserCloseResponse, AskUserQuestion, AskUserSubmission, PendingAskUser, PendingExtensionDialog, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, ExtensionDialogAnswer, ExtensionDialogCloseReason, ExtensionDialogCloseResponse, ExtensionDialogKind, ExtensionDialogOutcome, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebDockerMode, PiWebInstallationInfo, PiWebInstallationKind, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SessionStreamSnapshot, SessionUnreadAcknowledgeRequest, SessionUnreadCatalogSnapshot, SessionUnreadEvent, SessionUnreadSummary, SessionTreeNavigateRequest, SessionTreeNavigateResult, SessionTreeNode, SessionTreeNodeKind, SessionTreeSnapshot, SessionTreeSummaryChoice, SessionWarning, SessionWarningSeverity, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
export type { ActiveAgentProfileDescriptor, ArchiveSessionsResponse, AzureSpeechSettings, AzureSpeechSettingsUpdate, AzureSpeechToken, AskUserCloseResponse, AskUserQuestion, AskUserSubmission, PendingAskUser, PendingExtensionDialog, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, ExtensionDialogAnswer, ExtensionDialogCloseReason, ExtensionDialogCloseResponse, ExtensionDialogKind, ExtensionDialogOutcome, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebDockerMode, PiWebInstallationInfo, PiWebInstallationKind, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SessionStreamSnapshot, SessionUnreadAcknowledgeRequest, SessionUnreadCatalogSnapshot, SessionUnreadEvent, SessionUnreadSummary, SessionTreeNavigateRequest, SessionTreeNavigateResult, SessionTreeNode, SessionTreeNodeKind, SessionTreeSnapshot, SessionTreeSummaryChoice, SessionWarning, SessionWarningSeverity, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
+9 -1
View File
@@ -1,9 +1,11 @@
import type { AskUserSubmission, DeleteWorkspaceFileResponse, ExtensionDialogAnswer, FileSuggestion, MoveWorkspaceFileOptions, PiPackageInstallRequest, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionBulkMutationRef, SessionCleanupRequest, SessionNotificationDismissThrough, SessionRef, SessionTreeNavigateRequest, SessionUnreadAcknowledgeRequest, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes";
import type { AskUserSubmission, AzureSpeechSettings, AzureSpeechSettingsUpdate, AzureSpeechToken, DeleteWorkspaceFileResponse, ExtensionDialogAnswer, FileSuggestion, MoveWorkspaceFileOptions, PiPackageInstallRequest, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionBulkMutationRef, SessionCleanupRequest, SessionNotificationDismissThrough, SessionRef, SessionTreeNavigateRequest, SessionUnreadAcknowledgeRequest, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes";
import { resolveAppUrl } from "../appUrl";
import { request } from "./http";
import {
arrayOf,
parseAborted,
parseAzureSpeechSettings,
parseAzureSpeechToken,
parseAskUserCloseResponse,
parseAccepted,
parseArchived,
@@ -137,6 +139,12 @@ export const configApi = {
saveConfig: (config: PiWebConfigValues, machineId?: string) => request(configPath(machineId), parsePiWebConfigResponse, { method: "PUT", body: JSON.stringify({ config }) }),
};
export const azureSpeechApi = {
settings: (): Promise<AzureSpeechSettings> => request("api/azure-speech", parseAzureSpeechSettings),
saveSettings: (settings: AzureSpeechSettingsUpdate): Promise<AzureSpeechSettings> => request("api/azure-speech", parseAzureSpeechSettings, { method: "PUT", body: JSON.stringify(settings) }),
token: (): Promise<AzureSpeechToken> => request("api/azure-speech/token", parseAzureSpeechToken, { method: "POST" }),
};
export const pluginsApi = {
plugins: (machineId?: string) => request(pluginsPath(machineId), parsePiWebPluginsResponse),
};
+11 -1
View File
@@ -1,4 +1,4 @@
import { ASK_USER_ID_MAX_LENGTH, ASK_USER_OPTION_LIMIT, ASK_USER_OTHER_TEXT_MAX_LENGTH, ASK_USER_QUESTION_LIMIT, ASK_USER_TEXT_MAX_LENGTH, EXTENSION_DIALOG_ID_MAX_LENGTH, EXTENSION_DIALOG_INPUT_MAX_LENGTH, EXTENSION_DIALOG_OPTION_LIMIT, EXTENSION_DIALOG_TEXT_MAX_LENGTH, SESSION_NOTIFICATION_LIMIT, SESSION_NOTIFICATION_MESSAGE_BYTES, SESSION_UNREAD_CATALOG_ID_MAX_LENGTH, SESSION_UNREAD_COMPLETED_AT_MAX_LENGTH, SESSION_UNREAD_CWD_MAX_LENGTH, SESSION_UNREAD_LIMIT, SESSION_UNREAD_SESSION_ID_MAX_LENGTH, type ArchiveSessionsResponse, type AskUserCloseReason, type AskUserCloseResponse, type AskUserOutcome, type AskUserQuestion, type AskUserQuestionOption, type AskUserQuestionRecord, type PendingAskUser, type PendingExtensionDialog, type AuthProviderOption, type AuthProviderStatus, type AuthProvidersResponse, type AuthStatusSource, type AuthType, type CommandOption, type CommandResult, type DeleteWorkspaceFileResponse, type ExtensionDialogAnswer, type ExtensionDialogCloseReason, type ExtensionDialogCloseResponse, type ExtensionDialogKind, type ExtensionDialogOutcome, type FileContentResponse, type FileSuggestion, type FileTreeEntry, type FileTreeResponse, type GitDiffResponse, type GitFileState, type GitStatusFile, type GitStatusResponse, type Machine, type MachineHealth, type MachineKind, type MachineRuntime, type MachineStatus, type MessagePage, type ModelSelectionResponse, type MoveWorkspaceFileResponse, type OAuthFlowState, type PiWebAgentDirEnvSource, type PiWebCapability, type PiWebComponentStatus, type PiWebConfigEnvOverrides, type PiWebConfigResponse, type PiWebConfigValues, type PiWebInstallationInfo, type PiWebPluginConfigMap, type PiWebPluginInfo, type PiWebPluginsResponse, type PiWebPluginScope, type PiWebReleaseStatus, type PiWebRuntimeComponent, type PiWebRuntimeResponse, type PiWebServiceComponent, type PiWebShortcutConfig, type PiWebStatusMessage, type PiWebStatusResponse, type PiWebStatusSeverity, type Project, type QueuedSessionMessage, type SavedPromptAttachment, type SessionBulkArchiveResponse, type SessionBulkDeleteArchivedResponse, type SessionBulkFailure, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupProjectSummary, type SessionCleanupThresholds, type SessionCleanupTotals, type SessionInfo, type SessionModel, type SessionNotification, type SessionNotificationClearReason, type SessionNotificationDismissThrough, type SessionNotificationInboxDelta, type SessionNotificationInboxEvent, type SessionNotificationInboxSnapshot, type SessionNotificationSeverity, type SessionNotificationSummary, type SessionStatus, type SessionStreamSnapshot, type SessionUnreadCatalogSnapshot, type SessionUnreadEvent, type SessionUnreadSummary, type SessionWarning, type SessionWarningSeverity, type SlashCommand, type TerminalCommandRun, type TerminalCommandRunStatus, type TerminalInfo, type ThinkingLevelsResponse, type WriteWorkspaceFileResponse, type Workspace, type WorkspaceActivity, type WorkspaceActivityResponse } from "../../../shared/apiTypes";
import { ASK_USER_ID_MAX_LENGTH, ASK_USER_OPTION_LIMIT, ASK_USER_OTHER_TEXT_MAX_LENGTH, ASK_USER_QUESTION_LIMIT, ASK_USER_TEXT_MAX_LENGTH, EXTENSION_DIALOG_ID_MAX_LENGTH, EXTENSION_DIALOG_INPUT_MAX_LENGTH, EXTENSION_DIALOG_OPTION_LIMIT, EXTENSION_DIALOG_TEXT_MAX_LENGTH, SESSION_NOTIFICATION_LIMIT, SESSION_NOTIFICATION_MESSAGE_BYTES, SESSION_UNREAD_CATALOG_ID_MAX_LENGTH, SESSION_UNREAD_COMPLETED_AT_MAX_LENGTH, SESSION_UNREAD_CWD_MAX_LENGTH, SESSION_UNREAD_LIMIT, SESSION_UNREAD_SESSION_ID_MAX_LENGTH, type ArchiveSessionsResponse, type AzureSpeechSettings, type AzureSpeechToken, type AskUserCloseReason, type AskUserCloseResponse, type AskUserOutcome, type AskUserQuestion, type AskUserQuestionOption, type AskUserQuestionRecord, type PendingAskUser, type PendingExtensionDialog, type AuthProviderOption, type AuthProviderStatus, type AuthProvidersResponse, type AuthStatusSource, type AuthType, type CommandOption, type CommandResult, type DeleteWorkspaceFileResponse, type ExtensionDialogAnswer, type ExtensionDialogCloseReason, type ExtensionDialogCloseResponse, type ExtensionDialogKind, type ExtensionDialogOutcome, type FileContentResponse, type FileSuggestion, type FileTreeEntry, type FileTreeResponse, type GitDiffResponse, type GitFileState, type GitStatusFile, type GitStatusResponse, type Machine, type MachineHealth, type MachineKind, type MachineRuntime, type MachineStatus, type MessagePage, type ModelSelectionResponse, type MoveWorkspaceFileResponse, type OAuthFlowState, type PiWebAgentDirEnvSource, type PiWebCapability, type PiWebComponentStatus, type PiWebConfigEnvOverrides, type PiWebConfigResponse, type PiWebConfigValues, type PiWebInstallationInfo, type PiWebPluginConfigMap, type PiWebPluginInfo, type PiWebPluginsResponse, type PiWebPluginScope, type PiWebReleaseStatus, type PiWebRuntimeComponent, type PiWebRuntimeResponse, type PiWebServiceComponent, type PiWebShortcutConfig, type PiWebStatusMessage, type PiWebStatusResponse, type PiWebStatusSeverity, type Project, type QueuedSessionMessage, type SavedPromptAttachment, type SessionBulkArchiveResponse, type SessionBulkDeleteArchivedResponse, type SessionBulkFailure, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupProjectSummary, type SessionCleanupThresholds, type SessionCleanupTotals, type SessionInfo, type SessionModel, type SessionNotification, type SessionNotificationClearReason, type SessionNotificationDismissThrough, type SessionNotificationInboxDelta, type SessionNotificationInboxEvent, type SessionNotificationInboxSnapshot, type SessionNotificationSeverity, type SessionNotificationSummary, type SessionStatus, type SessionStreamSnapshot, type SessionUnreadCatalogSnapshot, type SessionUnreadEvent, type SessionUnreadSummary, type SessionWarning, type SessionWarningSeverity, type SlashCommand, type TerminalCommandRun, type TerminalCommandRunStatus, type TerminalInfo, type ThinkingLevelsResponse, type WriteWorkspaceFileResponse, type Workspace, type WorkspaceActivity, type WorkspaceActivityResponse } from "../../../shared/apiTypes";
import type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse, SessionActivity, SessionStartupProgressEvent, SessionTreeNavigateResult, SessionTreeNode, SessionTreeNodeKind, SessionTreeSnapshot } from "../../../shared/apiTypes";
import { parseActiveAgentProfileDescriptor } from "../../../shared/activeAgentProfile";
import { parseKnownPiWebCapabilities } from "../../../shared/capabilities";
@@ -1207,6 +1207,16 @@ export function parseWorkspaceActivityResponse(value: unknown): WorkspaceActivit
return { workspaces: arrayOf(parseWorkspaceActivity)(record["workspaces"]), generatedAt: requireString(record, "generatedAt") };
}
export function parseAzureSpeechSettings(value: unknown): AzureSpeechSettings {
const record = requireRecord(value);
return { region: requireString(record, "region"), voice: requireString(record, "voice"), hasApiKey: requireBoolean(record, "hasApiKey") };
}
export function parseAzureSpeechToken(value: unknown): AzureSpeechToken {
const record = requireRecord(value);
return { token: requireString(record, "token"), region: requireString(record, "region"), voice: requireString(record, "voice") };
}
export function parsePiWebConfigResponse(value: unknown): PiWebConfigResponse {
const record = requireRecord(value);
return {
+4 -3
View File
@@ -46,9 +46,10 @@ export interface AppState {
pendingDialogs: PendingExtensionDialog[];
/**
* Dialogs that closed while their session was selected, kept with the close
* reason and any answer so the card can render its outcome briefly. The wire
* outcome is deliberately small, so only a browser that saw the dialog open
* can show the closed card; deselection and reloads drop these.
* reason and any answer so the settled card can show what became of the
* dialog. The card stays until the user dismisses it. The wire outcome is
* deliberately small, so only a browser that saw the dialog open can show
* the closed card; deselection and reloads drop these.
*/
closedDialogs: ClosedExtensionDialog[];
/** Thinking levels available for the selected session's current model. */
+204
View File
@@ -0,0 +1,204 @@
import * as SpeechSDK from "microsoft-cognitiveservices-speech-sdk";
import { azureSpeechApi } from "./api";
import { transitionVoiceRecognition, type VoiceRecognitionPhase } from "./voiceRecognitionLifecycle";
const VOICE_MODE_STORAGE_KEY = "pi-web.azure-speech.voice-mode";
export function voiceModeEnabled(): boolean {
return window.localStorage.getItem(VOICE_MODE_STORAGE_KEY) === "true";
}
export function setVoiceModeEnabled(enabled: boolean): void {
window.localStorage.setItem(VOICE_MODE_STORAGE_KEY, String(enabled));
}
/** Call from the microphone tap handler so iOS permits later response playback. */
export function primeVoiceAudio(): void {
AzureSpeechClient.primeSpeaker();
}
export class AzureSpeechClient {
static speaker: HTMLAudioElement | undefined;
private recognizer: SpeechSDK.SpeechRecognizer | undefined;
private stream: MediaStream | undefined;
private phase: VoiceRecognitionPhase = "idle";
private generation = 0;
private synthesizer: SpeechSDK.SpeechSynthesizer | undefined;
static primeSpeaker(): void {
const speaker = this.speaker ?? new Audio();
this.speaker = speaker;
// A muted, zero-length WAV played directly from the mic button's user
// gesture unlocks this element for later Azure response playback on iOS.
speaker.muted = true;
speaker.src = "data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA=";
void speaker.play().then(() => { speaker.pause(); }).catch(() => undefined);
}
get recording(): boolean {
return this.phase !== "idle";
}
/**
* Begins one persistent microphone session. Its MediaStream is acquired in
* the button's user gesture and is retained between turns, which is required
* for iOS to accept follow-up speech without another tap.
*/
async startRecognition(options: { onReady: () => void; onInterim: (text: string) => void; onFinal: (text: string) => void; onStopped: () => void; }): Promise<void> {
if (this.phase !== "idle") return;
if (!window.isSecureContext || !hasMicrophoneAccess(navigator)) {
throw new Error("Voice input requires PI WEB to be opened over HTTPS. iPhone browsers do not expose the microphone to an http:// LAN address.");
}
const generation = ++this.generation;
// This must be the first asynchronous operation so getUserMedia starts in
// the microphone button's gesture, not after a token/network round trip.
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
this.stream = stream;
try {
const settings = await azureSpeechApi.token();
if (generation !== this.generation) { stopStream(stream); return; }
const speechConfig = SpeechSDK.SpeechConfig.fromAuthorizationToken(settings.token, settings.region);
speechConfig.speechRecognitionLanguage = navigator.language || "en-US";
speechConfig.setProperty(SpeechSDK.PropertyId.Speech_SegmentationSilenceTimeoutMs, "1200");
const recognizer = new SpeechSDK.SpeechRecognizer(speechConfig, SpeechSDK.AudioConfig.fromStreamInput(stream));
this.recognizer = recognizer;
this.phase = transitionVoiceRecognition(this.phase, "start");
recognizer.recognizing = (_sender, event) => {
if (this.generation === generation && this.phase === "listening") options.onInterim(event.result.text);
};
recognizer.recognized = (_sender, event) => {
if (this.generation !== generation || this.phase !== "listening") return;
if (event.result.reason !== SpeechSDK.ResultReason.RecognizedSpeech || event.result.text.trim() === "") return;
// Gate the already-authorized stream before notifying the UI. This
// prevents another utterance or TTS from being captured while PI works.
this.setPhase(transitionVoiceRecognition(this.phase, "final"));
options.onFinal(event.result.text);
};
recognizer.canceled = () => {
if (this.generation !== generation || this.phase === "idle") return;
this.releaseRecognition();
options.onStopped();
};
await callbackPromise((resolve, reject) => { recognizer.startContinuousRecognitionAsync(resolve, reject); });
if (this.generation !== generation) return;
options.onReady();
} catch (error) {
if (generation === this.generation) {
if (this.stream !== stream) stopStream(stream);
this.releaseRecognition();
}
throw error;
}
}
/** Re-enable the existing, user-authorized microphone after response audio. */
resumeRecognition(): void {
if (this.phase !== "paused") return;
this.setPhase(transitionVoiceRecognition(this.phase, "resume"));
}
async stopRecognition(): Promise<void> {
if (this.phase === "idle" && this.stream === undefined) return;
++this.generation;
const recognizer = this.recognizer;
this.releaseRecognition();
if (recognizer !== undefined) {
await callbackPromise((resolve, reject) => { recognizer.stopContinuousRecognitionAsync(resolve, reject); }).catch(() => undefined);
recognizer.close();
}
}
async speak(text: string): Promise<void> {
const spoken = text.trim();
if (spoken === "") return;
this.stopSpeaking();
const settings = await azureSpeechApi.token();
const speechConfig = SpeechSDK.SpeechConfig.fromAuthorizationToken(settings.token, settings.region);
if (settings.voice !== "") speechConfig.speechSynthesisVoiceName = settings.voice;
speechConfig.speechSynthesisOutputFormat = SpeechSDK.SpeechSynthesisOutputFormat.Audio24Khz48KBitRateMonoMp3;
const synthesizer = new SpeechSDK.SpeechSynthesizer(speechConfig, null);
this.synthesizer = synthesizer;
try {
const audioData = await synthesisAudio(synthesizer, spoken);
await playAudioData(audioData);
} finally {
if (this.synthesizer === synthesizer) this.synthesizer = undefined;
synthesizer.close();
}
}
stopSpeaking(): void {
const synthesizer = this.synthesizer;
this.synthesizer = undefined;
synthesizer?.close();
}
dispose(): void {
void this.stopRecognition();
this.stopSpeaking();
}
private setPhase(phase: VoiceRecognitionPhase): void {
this.phase = phase;
for (const track of this.stream?.getAudioTracks() ?? []) track.enabled = phase === "listening";
}
private releaseRecognition(): void {
this.phase = transitionVoiceRecognition(this.phase, "stop");
this.recognizer = undefined;
stopStream(this.stream);
this.stream = undefined;
}
}
function hasMicrophoneAccess(value: Navigator): boolean {
const mediaDevices: unknown = Reflect.get(value, "mediaDevices");
return typeof mediaDevices === "object" && mediaDevices !== null && typeof Reflect.get(mediaDevices, "getUserMedia") === "function";
}
function stopStream(stream: MediaStream | undefined): void {
for (const track of stream?.getTracks() ?? []) track.stop();
}
function synthesisAudio(synthesizer: SpeechSDK.SpeechSynthesizer, text: string): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
synthesizer.speakTextAsync(
text,
(result) => {
if (result.reason !== SpeechSDK.ResultReason.SynthesizingAudioCompleted) {
reject(new Error(`Azure Speech synthesis failed: ${result.errorDetails || String(result.reason)}`));
return;
}
resolve(result.audioData);
},
(error) => { reject(new Error(error)); },
);
});
}
function playAudioData(audioData: ArrayBuffer): Promise<void> {
const speaker = AzureSpeechClient.speaker ?? new Audio();
AzureSpeechClient.speaker = speaker;
speaker.muted = false;
const url = URL.createObjectURL(new Blob([audioData], { type: "audio/mpeg" }));
speaker.src = url;
return new Promise((resolve, reject) => {
const releaseAudioSession = () => {
speaker.onended = null;
speaker.onerror = null;
speaker.pause();
speaker.removeAttribute("src");
speaker.load();
URL.revokeObjectURL(url);
};
speaker.onended = () => { releaseAudioSession(); resolve(); };
speaker.onerror = () => { releaseAudioSession(); reject(new Error("Unable to play Azure Speech audio.")); };
void speaker.play().catch((error: unknown) => { releaseAudioSession(); reject(error instanceof Error ? error : new Error(String(error))); });
});
}
function callbackPromise(start: (resolve: () => void, reject: (error: string) => void) => void): Promise<void> {
return new Promise((resolve, reject) => { start(resolve, (error) => { reject(new Error(error)); }); });
}
+6
View File
@@ -113,6 +113,12 @@ describe("chat message normalization", () => {
]);
});
it("carries the thinking level into assistant message metadata", () => {
expect(normalizeMessage({ role: "assistant", content: [{ type: "text", text: "hi" }], provider: "openai", model: "gpt-4.1", timestamp: "2026-05-09T12:00:00.000Z", thinkingLevel: "max" })).toEqual([
{ role: "assistant", parts: [{ type: "text", text: "hi" }], meta: { timestamp: "2026-05-09T12:00:00.000Z", model: { provider: "openai", id: "gpt-4.1" }, thinkingLevel: "max" } },
]);
});
it("shows assistant model errors as system chat messages", () => {
expect(normalizeMessage({ role: "assistant", content: [], stopReason: "error", errorMessage: "429 rate limit", timestamp: "2026-05-09T12:00:00.000Z", provider: "openai", model: "gpt-4.1" })).toEqual([
{ role: "system", parts: [{ type: "text", text: "Model response failed: 429 rate limit" }], meta: { timestamp: "2026-05-09T12:00:00.000Z", model: { provider: "openai", id: "gpt-4.1" } } },
+7 -2
View File
@@ -109,8 +109,13 @@ function normalizeSource(message: unknown): ChatLine["source"] | undefined {
function normalizeMeta(message: unknown): ChatLine["meta"] | undefined {
const timestamp = normalizeTimestamp(getProperty(message, "timestamp"));
const model = normalizeModel(message);
if (timestamp === undefined && model === undefined) return undefined;
return { ...(timestamp === undefined ? {} : { timestamp }), ...(model === undefined ? {} : { model }) };
const thinkingLevel = getString(message, "thinkingLevel");
if (timestamp === undefined && model === undefined && (thinkingLevel === undefined || thinkingLevel === "")) return undefined;
return {
...(timestamp === undefined ? {} : { timestamp }),
...(model === undefined ? {} : { model }),
...(thinkingLevel === undefined || thinkingLevel === "" ? {} : { thinkingLevel }),
};
}
function normalizeTimestamp(value: unknown): string | undefined {
@@ -259,6 +259,17 @@ describe("chatMessageMetadataLabel", () => {
meta: { timestamp, model: { provider: "provider", id: "model" } },
})).toBe(`${formattedTimestamp} · provider/model`);
});
it("appends the thinking level after the model when present", () => {
const timestamp = "2026-07-10T19:15:30.000Z";
const formattedTimestamp = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "medium" }).format(new Date(timestamp));
expect(chatMessageMetadataLabel({
role: "assistant",
parts: [],
meta: { timestamp, model: { provider: "provider", id: "model" }, thinkingLevel: "high" },
})).toBe(`${formattedTimestamp} · provider/model · high`);
});
});
describe("chat event-group content seams", () => {
+1 -1
View File
@@ -163,7 +163,7 @@ export function chatMessageMetadataLabel(message: ChatLine): string {
const timestamp = message.meta?.timestamp;
const time = timestamp === undefined ? undefined : formatMessageTimestamp(timestamp);
const model = chatMessageModelLabel(message);
const parts = [time, model].filter((part): part is string => part !== undefined && part !== "");
const parts = [time, model, message.meta?.thinkingLevel].filter((part): part is string => part !== undefined && part !== "");
return parts.length === 0 ? "No Pi message metadata available" : parts.join(" · ");
}
@@ -141,13 +141,25 @@ describe("extension-dialog-card countdown", () => {
vi.setSystemTime(new Date("2026-07-27T10:00:00.000Z"));
const card = await mountOpenDialog(openDialog({ timeoutAt: "2026-07-27T10:01:30.000Z" }));
const root = renderRoot(card);
const status = requiredElement(root.querySelector("[role='status']"), "countdown status");
const countdown = requiredElement(root.querySelector(".countdown"), "countdown");
expect(status.textContent).toBe("Auto-cancels in 1m 30s");
expect(countdown.textContent).toBe("Auto-cancels in 1m 30s");
await vi.advanceTimersByTimeAsync(30_000);
await card.updateComplete;
expect(status.textContent).toBe("Auto-cancels in 1m 0s");
expect(countdown.textContent).toBe("Auto-cancels in 1m 0s");
});
it("is decorative: no live region announcing every second", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-27T10:00:00.000Z"));
const card = await mountOpenDialog(openDialog({ timeoutAt: "2026-07-27T10:01:30.000Z" }));
const root = renderRoot(card);
// A ticking live region would queue a screen-reader announcement per
// second; the daemon-owned dialog.closed event is the real signal.
expect(requiredElement(root.querySelector(".countdown"), "countdown").getAttribute("role")).toBeNull();
expect(root.querySelector("[aria-live]")).toBeNull();
});
it("renders no countdown when the dialog waits forever", async () => {
@@ -155,7 +167,7 @@ describe("extension-dialog-card countdown", () => {
const card = await mountOpenDialog(openDialog());
const root = renderRoot(card);
expect(root.querySelector("[role='status']")).toBeNull();
expect(root.querySelector(".countdown")).toBeNull();
});
it("stops ticking once the dialog closes", async () => {
@@ -219,6 +231,11 @@ describe("extensionDialogCountdownText", () => {
expect(extensionDialogCountdownText("2026-07-27T11:02:00.000Z", now)).toBe("Auto-cancels in 1h 2m");
});
it("never rounds the minutes up to 60 near an hour boundary", () => {
expect(extensionDialogCountdownText("2026-07-27T11:59:55.000Z", now)).toBe("Auto-cancels in 1h 59m");
expect(extensionDialogCountdownText("2026-07-27T12:59:40.000Z", now)).toBe("Auto-cancels in 2h 59m");
});
it("stays display-only once the deadline has passed", () => {
expect(extensionDialogCountdownText("2026-07-27T09:59:59.000Z", now)).toBe("Auto-cancel imminent");
});
@@ -58,7 +58,8 @@ export function extensionDialogCountdownText(timeoutAt: string | undefined, nowM
const seconds = Math.ceil(remainingMs / 1000);
if (seconds >= 3600) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.round((seconds % 3600) / 60);
// Floor, not round: rounding yields "1h 60m" in the last half-minute of an hour.
const minutes = Math.floor((seconds % 3600) / 60);
return `Auto-cancels in ${String(hours)}h ${String(minutes)}m`;
}
if (seconds >= 60) {
@@ -75,7 +76,8 @@ export function extensionDialogCountdownText(timeoutAt: string | undefined, nowM
* The card owns only browser-local form state (the half-typed input, the
* in-flight close flag, the display-only countdown); the daemon remains the
* source of truth for whether the dialog is open. Closed mode renders the
* transient outcome for a browser that saw the dialog open.
* settled outcome a browser-local record that stays until dismissed — for a
* browser that saw the dialog open.
*/
@customElement("extension-dialog-card")
export class ExtensionDialogCard extends LitElement {
@@ -129,7 +131,10 @@ export class ExtensionDialogCard extends LitElement {
<h2 id="extension-dialog-heading">${dialog.title}</h2>
${countdown === undefined
? null
: html`<span class="header-status countdown" role="status" aria-live="polite" aria-atomic="true">${countdown}</span>`}
// Decorative only — no live region: a polite region would queue one
// announcement per second. The daemon-owned dialog.closed event is
// the real signal, and the settled card announces the outcome.
: html`<span class="header-status countdown">${countdown}</span>`}
</header>
${this.renderOpenBody(dialog)}
</article>
+9
View File
@@ -76,6 +76,7 @@ import { shouldShowMachinesSection, type AppNavigationPanel, type NavigationFocu
import "./appShell/AppPanelEdgeControl";
import "./appShell/AppRefreshControl";
import { appStyles } from "./shared";
import { AzureSpeechClient, voiceModeEnabled } from "../azureSpeechClient";
const PI_WEB_STATUS_REFRESH_MS = 15 * 60 * 1000;
@@ -110,6 +111,7 @@ export class PiWebApp extends LitElement {
@query("#navigation-panel") private navigationPanelFrame?: HTMLElement;
@query("#workspace-panel") private workspacePanelFrame?: HTMLElement;
private readonly azureSpeech = new AzureSpeechClient();
private readonly sessionUnread = new SessionUnreadController({
onChange: (machineId) => {
this.syncUnreadPresence();
@@ -142,6 +144,12 @@ export class PiWebApp extends LitElement {
onSelectedSessionReady: ({ machineId, session }) => {
void this.commitReadyChatAfterRender(machineId, session);
},
onFinalAssistantResponse: ({ text }) => {
if (!voiceModeEnabled()) return;
void this.azureSpeech.speak(text)
.catch((error: unknown) => { console.warn("Azure Speech synthesis failed", error); })
.finally(() => { this.promptEditor?.resumeVoiceInput(); });
},
replacePromptEditorText: async ({ machineId, sessionId, text }) => {
await this.updateComplete;
if (selectedMachineId(this.state) !== machineId || this.state.selectedSession?.id !== sessionId) return;
@@ -397,6 +405,7 @@ export class PiWebApp extends LitElement {
this.systemLightThemeMedia?.removeEventListener("change", this.onSystemLightThemeChange);
this.keyboard.reset();
this.auth.dispose();
this.azureSpeech.dispose();
this.sessions.dispose();
this.notifications.dispose();
this.realtime.close();
@@ -0,0 +1,81 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { api, type SessionModel } from "../api";
import { PromptEditor } from "./PromptEditor";
const sonnet: SessionModel = { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" };
const gpt: SessionModel = { provider: "openai", id: "gpt-5.2" };
afterEach(() => {
vi.restoreAllMocks();
});
describe("PromptEditor model completions", () => {
it("requests models for the session and maps a # token to model completions", async () => {
const models = vi.spyOn(api, "models").mockResolvedValue({ models: [sonnet, gpt] });
const editor = new PromptEditor();
editor.sessionId = "session-1";
editor.cwd = "/repo";
editor.machineId = "remote-a";
await refreshCompletions(editor, "please use #cla");
expect(models).toHaveBeenCalledWith({ id: "session-1", cwd: "/repo" }, "remote-a");
expect(currentCompletions(editor)).toEqual([
{ kind: "model", replaceFrom: 11, replaceTo: 15, insertText: "#anthropic/claude-sonnet-4-5", detail: "anthropic", description: "Claude Sonnet 4.5" },
]);
expect(Reflect.get(editor, "selectedIndex")).toBe(0);
});
it("lists every model for a bare # token", async () => {
vi.spyOn(api, "models").mockResolvedValue({ models: [sonnet, gpt] });
const editor = new PromptEditor();
editor.sessionId = "session-1";
editor.cwd = "/repo";
await refreshCompletions(editor, "#");
expect(currentCompletions(editor)).toEqual([
{ kind: "model", replaceFrom: 0, replaceTo: 1, insertText: "#anthropic/claude-sonnet-4-5", detail: "anthropic", description: "Claude Sonnet 4.5" },
{ kind: "model", replaceFrom: 0, replaceTo: 1, insertText: "#openai/gpt-5.2", detail: "openai" },
]);
});
it("clears completions when the models request fails", async () => {
vi.spyOn(api, "models").mockRejectedValue(new Error("models unavailable"));
const editor = new PromptEditor();
editor.sessionId = "session-1";
editor.cwd = "/repo";
await refreshCompletions(editor, "#cla");
expect(currentCompletions(editor)).toEqual([]);
});
it("does not request models without session context", async () => {
const models = vi.spyOn(api, "models").mockResolvedValue({ models: [sonnet] });
const editor = new PromptEditor();
await refreshCompletions(editor, "#cla");
expect(models).not.toHaveBeenCalled();
expect(currentCompletions(editor)).toEqual([]);
});
});
// refreshCompletions is private and driven by CodeMirror updates in production;
// invoking it through Reflect mirrors the PromptEditor.draft.test.ts seam and
// keeps the wiring test at the component boundary without a DOM harness.
async function refreshCompletions(editor: PromptEditor, draft: string): Promise<void> {
Reflect.set(editor, "draft", draft);
const refresh: unknown = Reflect.get(editor, "refreshCompletions");
if (!isRefreshCompletions(refresh)) throw new Error("PromptEditor.refreshCompletions is not callable");
await refresh.call(editor);
}
function isRefreshCompletions(value: unknown): value is (this: PromptEditor) => Promise<void> {
return typeof value === "function";
}
function currentCompletions(editor: PromptEditor): unknown {
return Reflect.get(editor, "completions");
}
+105 -4
View File
@@ -5,17 +5,18 @@ import { EditorView, keymap, placeholder } from "@codemirror/view";
import { defaultHighlightStyle, indentOnInput, indentUnit, syntaxHighlighting } from "@codemirror/language";
import { LitElement, html, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import { api, type FileSuggestion, type PromptAttachment, type SessionStatus, type SlashCommand } from "../api";
import { api, type FileSuggestion, type PromptAttachment, type SessionModel, type SessionStatus, type SlashCommand } from "../api";
import { AzureSpeechClient, primeVoiceAudio, setVoiceModeEnabled } from "../azureSpeechClient";
import type { PromptAttachmentDelivery } from "../../../shared/apiTypes";
import { capturePromptAttachments, effectivePromptAttachmentDelivery, isInlinePromptAttachment, promptAttachmentsCanUseInlineDelivery, type CapturedAttachment } from "../promptAttachmentCapture";
import { inputModeForDraft, inputModesEqual, type InputMode } from "../inputModes";
import { machineSessionKey } from "../machineKeys";
import { detectPromptCompletionTrigger, fileCompletionInsertText, type PromptCompletionTrigger } from "../promptCompletions";
import { detectPromptCompletionTrigger, fileCompletionInsertText, modelCompletionChoices, type PromptCompletionTrigger } from "../promptCompletions";
import { clearDraft, loadDraft, saveDraft } from "../promptDraftStorage";
import { loadAttachmentDelivery, saveAttachmentDelivery } from "../attachmentPreferences";
import { createMobilePromptEnterMedia, readPromptEnterPreference, shouldSendPromptOnEnterShortcut, shouldUsePromptEnterShiftShortcut } from "../promptEnterBehavior";
import { promptEditorStyles, type CompletionItem } from "./shared";
import { renderAttachIcon, renderSendIcon, renderQueueIcon, renderSteerIcon, renderStopIcon, renderThinkingGauge } from "./promptEditorIcons";
import { renderAttachIcon, renderSendIcon, renderQueueIcon, renderSteerIcon, renderStopIcon, renderThinkingGauge, renderVoiceIcon } from "./promptEditorIcons";
import { thinkingGauge, thinkingLevelLabel } from "../../../shared/thinkingLevels";
import "./AutocompleteMenu";
@@ -55,6 +56,11 @@ export class PromptEditor extends LitElement {
@state() private attachments: PendingAttachment[] = [];
@state() private attachmentDelivery: PromptAttachmentDelivery = loadAttachmentDelivery();
@state() private attachmentError: string | undefined = undefined;
@state() private voiceRecording = false;
@state() private voiceError: string | undefined = undefined;
private readonly azureSpeech = new AzureSpeechClient();
private voiceTranscript = "";
private voicePrefix = "";
private attachmentSeq = 0;
private requestVersion = 0;
private editor: EditorView | undefined;
@@ -97,6 +103,7 @@ export class PromptEditor extends LitElement {
}
override disconnectedCallback(): void {
this.azureSpeech.dispose();
this.editor?.destroy();
this.editor = undefined;
super.disconnectedCallback();
@@ -116,10 +123,12 @@ export class PromptEditor extends LitElement {
${shellMode ? html`<div class="mode-hint">Shell command${shellInputMode.excludeFromContext ? " · excluded from context" : ""}</div>` : null}
${this.isCompacting && !shellMode ? html`<div class="mode-hint">Compacting history · message will be queued</div>` : null}
${this.renderAttachments()}
${this.voiceError === undefined ? null : html`<div class="mode-hint voice-error" role="alert">${this.voiceError}</div>`}
<autocomplete-menu .items=${this.completions} .selectedIndex=${this.selectedIndex} .onPick=${(item: CompletionItem) => { this.pick(item); }}></autocomplete-menu>
</div>
<div class="actions">
${this.renderCompactStatus()}
<button class=${`icon-button voice-button${this.voiceRecording ? " voice-recording" : ""}`} ?disabled=${busy} title=${this.voiceRecording ? "Cancel voice input" : "Start voice message"} aria-label=${this.voiceRecording ? "Cancel voice input" : "Start voice message"} @click=${() => { void this.toggleVoice(); }}>${renderVoiceIcon(this.voiceRecording)}</button>
<button class="icon-button send-button" ?disabled=${busy} title=${queuesInput ? "Queue until the current activity finishes" : "Send message"} aria-label=${queuesInput ? "Queue message" : "Send message"} @click=${() => { this.send("followUp"); }}>${queuesInput ? renderQueueIcon() : renderSendIcon()}</button>
${this.canSteer && !this.isCompacting ? html`<button class="icon-button steer-button" ?disabled=${busy} title="Steer the current response before the next model call" aria-label="Steer current response" @click=${() => { this.send("steer"); }}>${renderSteerIcon()}</button>` : null}
<button class="icon-button stop-button" ?disabled=${this.disabled || !this.canStop} title=${this.canStop ? "Stop current work and clear queued messages" : "Nothing running"} aria-label="Stop current work" @click=${() => this.onStop?.()}>${renderStopIcon()}</button>
@@ -283,7 +292,7 @@ export class PromptEditor extends LitElement {
keyup: (event) => this.handleEditorKeyUp(event),
blur: () => this.resetEditorModifierState(),
}),
placeholder("Message pi... Use / for commands, @ for tracked files, @ space for all files"),
placeholder("Message pi... Use / for commands, @ for tracked files, @ space for all files, # for models"),
this.editableCompartment.of(EditorView.editable.of(!this.disabled)),
this.readOnlyCompartment.of(EditorState.readOnly.of(this.disabled)),
EditorView.updateListener.of((update) => {
@@ -372,6 +381,15 @@ export class PromptEditor extends LitElement {
...(file.path.endsWith("/") && insertText.endsWith("\"") ? { cursorOffset: insertText.length - 1 } : {}),
};
});
} else if (trigger.kind === "model" && this.sessionId !== undefined && this.sessionId !== "" && this.cwd !== undefined && this.cwd !== "") {
const models = await api.models({ id: this.sessionId, cwd: this.cwd }, this.machineId).then((response) => response.models).catch(emptySessionModels);
if (version !== this.requestVersion) return;
this.completions = modelCompletionChoices(models, trigger.query).map((choice) => ({
kind: "model",
replaceFrom: trigger.from,
replaceTo: trigger.to,
...choice,
}));
}
}
@@ -458,6 +476,55 @@ export class PromptEditor extends LitElement {
this.completions = [];
}
private async toggleVoice(): Promise<void> {
this.voiceError = undefined;
if (this.voiceRecording) {
this.voiceRecording = false;
setVoiceModeEnabled(false);
await this.azureSpeech.stopRecognition();
return;
}
// Must run directly in the button gesture so iOS authorizes this stream
// for every follow-up turn, not just the first one.
primeVoiceAudio();
this.voiceRecording = true;
setVoiceModeEnabled(true);
this.voicePrefix = this.draft.trim();
this.voiceTranscript = "";
try {
await this.azureSpeech.startRecognition({
onReady: prepareVoiceReadyChime(),
onInterim: (text) => { this.replaceText(this.voiceText(text)); },
onFinal: (text) => {
this.voiceTranscript = text;
this.replaceText(this.voiceText());
this.send("followUp");
},
onStopped: () => {
if (!this.voiceRecording) return;
this.voiceRecording = false;
setVoiceModeEnabled(false);
},
});
} catch (error) {
this.voiceError = errorMessage(error);
this.voiceRecording = false;
setVoiceModeEnabled(false);
}
}
/** Called by PiWebApp after final TTS playback (or a synthesis failure). */
resumeVoiceInput(): void {
if (!this.voiceRecording) return;
this.voicePrefix = this.draft.trim();
this.voiceTranscript = "";
this.azureSpeech.resumeRecognition();
}
private voiceText(interim = ""): string {
return [this.voicePrefix, this.voiceTranscript, interim].filter((part) => part.trim() !== "").join(" ");
}
private send(streamingBehavior?: "steer" | "followUp") {
if (this.disabled || this.sending) return;
const text = this.draft.trim();
@@ -516,6 +583,10 @@ function emptyFileSuggestions(): FileSuggestion[] {
return [];
}
function emptySessionModels(): SessionModel[] {
return [];
}
function filesFromDataTransfer(data: DataTransfer | null): File[] {
if (data === null) return [];
return Array.from(data.files);
@@ -571,6 +642,36 @@ const codeLikeInputAssistanceAttributes: Record<string, string> = {
dir: "auto",
};
function prepareVoiceReadyChime(): () => void {
// Create and resume the context in the tap handler. That keeps iOS' user-
// gesture audio permission alive until Azure confirms the microphone session.
const context = new AudioContext();
void context.resume();
let played = false;
return () => {
if (played) return;
played = true;
const start = context.currentTime;
for (const [offset, frequency] of [[0, 523.25], [0.09, 783.99]] as const) {
const oscillator = context.createOscillator();
const gain = context.createGain();
oscillator.type = "sine";
oscillator.frequency.setValueAtTime(frequency, start + offset);
gain.gain.setValueAtTime(0.0001, start + offset);
gain.gain.exponentialRampToValueAtTime(0.055, start + offset + 0.018);
gain.gain.exponentialRampToValueAtTime(0.0001, start + offset + 0.24);
oscillator.connect(gain).connect(context.destination);
oscillator.start(start + offset);
oscillator.stop(start + offset + 0.25);
}
window.setTimeout(() => { void context.close(); }, 500);
};
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function inputAssistanceContentAttributes(draftBeforeCursor: string): Record<string, string> {
// CodeMirror is optimized for code and disables these by default, but the chat prompt is usually prose.
return inputModeForDraft(draftBeforeCursor).kind === "normal" ? proseInputAssistanceAttributes : codeLikeInputAssistanceAttributes;
@@ -0,0 +1,157 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it } from "vitest";
import type { SessionInfo } from "../api";
import { SessionList } from "./SessionList";
afterEach(() => {
document.body.replaceChildren();
localStorage.clear();
});
describe("SessionList bulk selection toolbar", () => {
it("offers Select visible with an empty selection and no Clear or Done buttons", async () => {
const list = await renderSessionList([session("a"), session("b"), session("c")]);
currentSelectionToggle(list).click();
await list.updateComplete;
expect(toolbarButton(list, "Select visible")).not.toBeNull();
expect(toolbarButton(list, "Clear selected")).toBeNull();
expect(toolbarButton(list, "Clear")).toBeNull();
expect(toolbarButton(list, "Done")).toBeNull();
expect(selectionCount(list)?.textContent.trim()).toBe("0 selected");
});
it("selects every visible session via Select visible, then clears them via Clear selected", async () => {
const list = await renderSessionList([session("a"), session("b"), session("c")]);
currentSelectionToggle(list).click();
await list.updateComplete;
toolbarButton(list, "Select visible")?.click();
await list.updateComplete;
expect(checkedBoxes(list)).toHaveLength(3);
expect(selectionCount(list)?.textContent.trim()).toBe("3 selected");
expect(toolbarButton(list, "Select visible")).toBeNull();
toolbarButton(list, "Clear selected")?.click();
await list.updateComplete;
expect(checkedBoxes(list)).toHaveLength(0);
expect(selectionCount(list)?.textContent.trim()).toBe("0 selected");
// Clearing keeps selection mode open so the visible set can be re-selected.
expect(toolbarButton(list, "Select visible")).not.toBeNull();
});
it("clears a partial manual selection via Clear selected", async () => {
const list = await renderSessionList([session("a"), session("b"), session("c")]);
currentSelectionToggle(list).click();
await list.updateComplete;
checkboxes(list)[0]?.click();
await list.updateComplete;
expect(selectionCount(list)?.textContent.trim()).toBe("1 selected");
expect(toolbarButton(list, "Select visible")).toBeNull();
toolbarButton(list, "Clear selected")?.click();
await list.updateComplete;
expect(checkedBoxes(list)).toHaveLength(0);
expect(toolbarButton(list, "Select visible")).not.toBeNull();
});
it("closes selection mode, discarding the selection, from the same heading toggle that opened it", async () => {
const list = await renderSessionList([session("a"), session("b"), session("c")]);
currentSelectionToggle(list).click();
await list.updateComplete;
toolbarButton(list, "Select visible")?.click();
await list.updateComplete;
expect(checkedBoxes(list)).toHaveLength(3);
currentSelectionToggle(list).click();
await list.updateComplete;
expect(list.shadowRoot?.querySelector(".bulk-row.selecting")).toBeNull();
expect(checkboxes(list)).toHaveLength(0);
});
it("offers the same toggle in the archived scope", async () => {
const archivedA = session("archived-a", { archived: true, archivedAt: "2026-06-09T00:00:00.000Z" });
const archivedB = session("archived-b", { archived: true, archivedAt: "2026-06-09T00:00:00.000Z" });
const list = await renderSessionList([session("current"), archivedA, archivedB]);
archivedSectionToggle(list)?.click();
await list.updateComplete;
archivedSelectionToggle(list).click();
await list.updateComplete;
toolbarButton(list, "Select visible")?.click();
await list.updateComplete;
expect(checkedBoxes(list)).toHaveLength(2);
expect(selectionCount(list)?.textContent.trim()).toBe("2 selected");
toolbarButton(list, "Clear selected")?.click();
await list.updateComplete;
expect(checkedBoxes(list)).toHaveLength(0);
expect(toolbarButton(list, "Select visible")).not.toBeNull();
});
});
async function renderSessionList(sessions: SessionInfo[]): Promise<SessionList> {
const list = new SessionList();
list.sessions = sessions;
document.body.append(list);
await list.updateComplete;
return list;
}
function currentSelectionToggle(list: SessionList): HTMLButtonElement {
const button = list.shadowRoot?.querySelector<HTMLButtonElement>("h2:not(.subheading) .bulk-select-entry");
if (button === null || button === undefined) throw new Error("Expected the current selection toggle");
return button;
}
function archivedSelectionToggle(list: SessionList): HTMLButtonElement {
const button = list.shadowRoot?.querySelector<HTMLButtonElement>("h2.subheading .bulk-select-entry");
if (button === null || button === undefined) throw new Error("Expected the archived selection toggle");
return button;
}
function archivedSectionToggle(list: SessionList): HTMLButtonElement | null {
return list.shadowRoot?.querySelector<HTMLButtonElement>("h2.subheading .section-toggle") ?? null;
}
function toolbarButton(list: SessionList, text: string): HTMLButtonElement | null {
const buttons = list.shadowRoot?.querySelectorAll<HTMLButtonElement>(".bulk-row.selecting button") ?? [];
for (const button of buttons) {
if (button.textContent.trim() === text) return button;
}
return null;
}
function selectionCount(list: SessionList): HTMLElement | null {
return list.shadowRoot?.querySelector<HTMLElement>(".bulk-row.selecting small") ?? null;
}
function checkboxes(list: SessionList): HTMLInputElement[] {
return [...(list.shadowRoot?.querySelectorAll<HTMLInputElement>("input.session-checkbox") ?? [])];
}
function checkedBoxes(list: SessionList): HTMLInputElement[] {
return checkboxes(list).filter((checkbox) => checkbox.checked);
}
function session(id: string, overrides: Partial<SessionInfo> = {}): SessionInfo {
return {
id,
path: `/sessions/${id}.jsonl`,
cwd: "/workspace",
created: "2026-06-09T00:00:00.000Z",
modified: "2026-06-09T00:00:00.000Z",
messageCount: 1,
firstMessage: id,
...overrides,
};
}
+24 -21
View File
@@ -230,16 +230,11 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
const selectedSessions = this.selectedSessions("current");
const archivableSessions = selectedSessions.filter((session) => isArchivableSessionInfo(session, this.statuses[session.id], this.sessionPersistenceOptions()));
const unreadSelectedSessions = selectedSessions.filter((session) => this.unreadSessionIds.has(session.id));
const allVisibleSelected = visibleSessions.length > 0 && visibleSessions.every((session) => this.selectedSessionIds.has(session.id));
const visibleSelectedCount = visibleSessions.filter((session) => this.selectedSessionIds.has(session.id)).length;
return html`
<div class="bulk-row selecting">
<button ?disabled=${visibleSessions.length === 0} @click=${() => { this.toggleVisibleSelection(visibleSessions, !allVisibleSelected); }}>${allVisibleSelected ? "Clear visible" : "Select visible"}</button>
<small>${selectedSessions.length} selected${visibleSelectedCount !== selectedSessions.length ? html` · ${visibleSelectedCount} visible` : null}</small>
<button ?disabled=${archivableSessions.length === 0} @click=${() => { this.archiveSelectedCurrent(); }}>Archive selected</button>
${this.renderSelectionControls("current", visibleSessions)}
<button ?disabled=${archivableSessions.length === 0} @click=${() => { this.archiveSelectedCurrent(); }}>Archive</button>
<button ?disabled=${unreadSelectedSessions.length === 0} @click=${() => { this.markSelectedCurrentRead(); }}>Mark read</button>
<button @click=${() => { this.clearSelection("current"); }}>Clear</button>
<button @click=${() => { this.closeSelection("current"); }}>Done</button>
</div>
`;
}
@@ -248,20 +243,33 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
if (visibleSessions.length === 0 || !this.selectionScopes.has("archived")) return null;
const selectedSessions = this.selectedSessions("archived");
const allVisibleSelected = visibleSessions.length > 0 && visibleSessions.every((session) => this.selectedSessionIds.has(session.id));
const visibleSelectedCount = visibleSessions.filter((session) => this.selectedSessionIds.has(session.id)).length;
return html`
<div class="bulk-row selecting">
<button ?disabled=${visibleSessions.length === 0} @click=${() => { this.toggleVisibleSelection(visibleSessions, !allVisibleSelected); }}>${allVisibleSelected ? "Clear visible" : "Select visible"}</button>
<small>${selectedSessions.length} selected${visibleSelectedCount !== selectedSessions.length ? html` · ${visibleSelectedCount} visible` : null}</small>
<button class="danger" title=${this.canDeleteArchived ? "Permanently delete selected archived sessions" : this.archivedDeleteUnavailableMessage} ?disabled=${selectedSessions.length === 0 || !this.canDeleteArchived} @click=${() => { this.confirmDeleteSelectedArchived(); }}>Delete selected</button>
<button @click=${() => { this.clearSelection("archived"); }}>Clear</button>
<button @click=${() => { this.closeSelection("archived"); }}>Done</button>
${this.renderSelectionControls("archived", visibleSessions)}
<button class="danger" title=${this.canDeleteArchived ? "Permanently delete selected archived sessions" : this.archivedDeleteUnavailableMessage} ?disabled=${selectedSessions.length === 0 || !this.canDeleteArchived} @click=${() => { this.confirmDeleteSelectedArchived(); }}>Delete</button>
${this.canDeleteArchived ? null : html`<small class="capability-hint">${this.archivedDeleteUnavailableMessage}</small>`}
</div>
`;
}
/**
* Shared selection toggle and count for both scopes. The toggle is binary:
* an empty selection offers to select every visible session, and any
* existing selection offers to clear the whole scope. Selection mode itself
* is exited from the same heading button that opened it, so the toolbar
* carries no separate Done or Clear buttons.
*/
private renderSelectionControls(scope: SessionSelectionScope, visibleSessions: SessionInfo[]) {
const selectedCount = this.selectedSessions(scope).length;
const visibleSelectedCount = visibleSessions.filter((session) => this.selectedSessionIds.has(session.id)).length;
return html`
${selectedCount === 0
? html`<button @click=${() => { this.selectVisibleSessions(visibleSessions); }}>Select visible</button>`
: html`<button @click=${() => { this.clearSelection(scope); }}>Clear selected</button>`}
<small>${selectedCount} selected${visibleSelectedCount !== selectedCount ? html` · ${visibleSelectedCount} visible` : null}</small>
`;
}
private renderSession(row: SessionRow, descendantCount: number, scope: SessionSelectionScope) {
const { session } = row;
const cappedDepth = Math.min(row.depth, 2);
@@ -445,13 +453,8 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
this.selectedSessionIds = next;
}
private toggleVisibleSelection(sessions: SessionInfo[], selected: boolean): void {
const next = new Set(this.selectedSessionIds);
for (const session of sessions) {
if (selected) next.add(session.id);
else next.delete(session.id);
}
this.selectedSessionIds = next;
private selectVisibleSessions(sessions: SessionInfo[]): void {
this.selectedSessionIds = new Set([...this.selectedSessionIds, ...sessions.map((session) => session.id)]);
}
private selectedSessions(scope: SessionSelectionScope): SessionInfo[] {
+9 -1
View File
@@ -4,6 +4,7 @@ import type { AppAction } from "../actions";
import { configApi, piPackagesApi, pluginsApi, type Machine, type MachineRuntime, type PiPackageMutationResponse, type PiPackageScope, type PiPackagesResponse, type PiWebConfigResponse, type PiWebConfigValues, type PiWebPluginsResponse } from "../api";
import type { SettingsSection } from "../settingsRoute";
import "./settings/SettingsGeneralPanel";
import "./settings/SettingsAzureSpeechPanel";
import "./settings/SettingsSessiondPanel";
import "./settings/SettingsPackagesPanel";
import "./settings/SettingsPluginsPanel";
@@ -118,6 +119,7 @@ export class SettingsDialog extends LitElement {
${this.renderNavButton("packages", "Pi packages", "Selected machine")}
${this.renderNavButton("plugins", "PI WEB plugins", "Selected machine")}
${this.renderNavButton("shortcuts", "Keyboard", "Gateway shortcuts")}
${this.renderNavButton("azure-speech", "Azure Speech", "Voice input + replies")}
</nav>
<main class="settings-content">
${this.renderActiveSection()}
@@ -148,6 +150,9 @@ export class SettingsDialog extends LitElement {
></settings-sessiond-panel>
`;
}
if (this.section === "azure-speech") {
return html`<settings-azure-speech-panel></settings-azure-speech-panel>`;
}
if (this.section === "shortcuts") {
return html`
<settings-shortcuts-panel
@@ -686,7 +691,8 @@ export type SettingsPanelTag =
| "settings-sessiond-panel"
| "settings-packages-panel"
| "settings-plugins-panel"
| "settings-shortcuts-panel";
| "settings-shortcuts-panel"
| "settings-azure-speech-panel";
/**
* The single custom-element panel the settings dialog renders for a section.
@@ -706,6 +712,8 @@ export function activeSettingsPanelTag(section: SettingsSection): SettingsPanelT
return "settings-plugins-panel";
case "shortcuts":
return "settings-shortcuts-panel";
case "azure-speech":
return "settings-azure-speech-panel";
case "general":
return "settings-general-panel";
}
@@ -13,6 +13,17 @@ export function renderAttachIcon(): TemplateResult {
`;
}
export function renderVoiceIcon(recording = false): TemplateResult {
return svg`
<svg class=${`prompt-action-icon${recording ? " prompt-action-icon-filled" : ""}`} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<rect x="9" y="3" width="6" height="11" rx="3"></rect>
<path d="M6 11a6 6 0 0 0 12 0"></path>
<path d="M12 17v4"></path>
<path d="M8 21h8"></path>
</svg>
`;
}
export function renderSendIcon(): TemplateResult {
return svg`
<svg class="prompt-action-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
@@ -0,0 +1,123 @@
import { css, html, LitElement, type TemplateResult } from "lit";
import { customElement, state } from "lit/decorators.js";
import { azureSpeechApi, type AzureSpeechSettings } from "../../api";
import "./SettingsPanelFrame";
import type { SettingsNotice } from "./SettingsPanelFrame";
@customElement("settings-azure-speech-panel")
export class SettingsAzureSpeechPanel extends LitElement {
@state() private settings: AzureSpeechSettings | undefined;
@state() private region = "eastus";
@state() private voice = "";
@state() private apiKey = "";
@state() private loading = true;
@state() private saving = false;
@state() private error = "";
@state() private saved = "";
override connectedCallback(): void {
super.connectedCallback();
void this.load();
}
override render(): TemplateResult {
return html`
<settings-panel-frame
heading="Azure Speech"
description="Real-time speech recognition and spoken final replies. Your key is stored only on this PI WEB host and is never returned to the browser."
actionLabel="Reload"
.actionDisabled=${this.loading || this.saving}
.notices=${this.notices()}
.onAction=${() => { void this.load(); }}
>
<section class="settings-card">
<div class="card-heading">
<h3>Azure AI Speech</h3>
<p>Use the region for your Speech resource and the exact Azure voice name you want for text-to-speech. Leave the voice empty to use Azure's default voice.</p>
</div>
${this.loading ? html`<div class="loading-card">Loading Azure Speech settings…</div>` : html`
<form class="config-form" @submit=${(event: Event) => { void this.save(event); }}>
<label class="field">
<span>Speech region</span>
<input .value=${this.region} required autocomplete="off" spellcheck="false" placeholder="eastus" @input=${(event: Event) => { this.region = inputValue(event); this.error = ""; }}>
<small>For example, <code>eastus</code>. This must match the Azure Speech resource.</small>
</label>
<label class="field">
<span>DragonHD voice name</span>
<input .value=${this.voice} autocomplete="off" spellcheck="false" placeholder="en-US-Andrew:DragonHDLatestNeural" @input=${(event: Event) => { this.voice = inputValue(event); this.error = ""; }}>
<small>Paste the exact voice identifier available to your resource.</small>
</label>
<label class="field">
<span>Speech API key</span>
<input type="password" .value=${this.apiKey} autocomplete="new-password" spellcheck="false" placeholder=${this.settings?.hasApiKey === true ? "Saved — enter a replacement or leave blank" : "Azure AI Speech key"} @input=${(event: Event) => { this.apiKey = inputValue(event); this.error = ""; }}>
<small>${this.settings?.hasApiKey === true ? "A key is already saved. Leaving this blank keeps it unchanged; entering a new value replaces it." : "The key is sent once over your PI WEB connection and stored in a separate owner-only file."}</small>
</label>
<footer class="form-actions"><button class="primary" ?disabled=${this.saving}>${this.saving ? "Saving…" : "Save Azure Speech settings"}</button></footer>
</form>
`}
</section>
</settings-panel-frame>
`;
}
private notices(): readonly SettingsNotice[] {
return [
...(this.error === "" ? [] : [{ type: "error" as const, content: this.error }]),
...(this.saved === "" ? [] : [{ type: "success" as const, content: this.saved }]),
];
}
private async load(): Promise<void> {
this.loading = true;
this.error = "";
try {
const settings = await azureSpeechApi.settings();
this.settings = settings;
this.region = settings.region;
this.voice = settings.voice;
this.apiKey = "";
} catch (error) {
this.error = errorMessage(error);
} finally {
this.loading = false;
}
}
private async save(event: Event): Promise<void> {
event.preventDefault();
this.saving = true;
this.error = "";
this.saved = "";
try {
this.settings = await azureSpeechApi.saveSettings({ region: this.region, voice: this.voice, ...(this.apiKey === "" ? {} : { apiKey: this.apiKey }) });
this.apiKey = "";
this.saved = "Azure Speech settings saved.";
} catch (error) {
this.error = errorMessage(error);
} finally {
this.saving = false;
}
}
static override styles = css`
:host { display: block; }
.settings-card, .loading-card { display: grid; gap: 14px; border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
.card-heading, .config-form, .field { display: grid; gap: 7px; }
h3, p { margin: 0; } p, small { color: var(--pi-muted); line-height: 1.45; }
.field > span { color: var(--pi-muted); font-size: 12px; font-weight: 700; text-transform: uppercase; }
input, button { font: inherit; } input { box-sizing: border-box; width: 100%; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); padding: 9px 10px; }
input:focus { outline: none; border-color: var(--pi-accent); box-shadow: 0 0 0 1px var(--pi-accent-border); }
code { font: 12px ui-monospace, monospace; }
.form-actions { display: flex; justify-content: flex-end; padding-top: 2px; }
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
button:disabled { opacity: .55; cursor: not-allowed; } .primary { border-color: var(--pi-accent); background: var(--pi-selection-bg); color: var(--pi-text-bright); }
`;
}
function inputValue(event: Event): string {
return event.target instanceof HTMLInputElement ? event.target.value : "";
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+3 -1
View File
@@ -68,11 +68,13 @@ export interface ChatLine {
meta?: {
timestamp?: string;
model?: { provider?: string; id?: string; responseId?: string };
/** Thinking level the assistant message was generated with, when known. */
thinkingLevel?: string;
};
}
export interface CompletionItem {
kind: "command" | "file";
kind: "command" | "file" | "model";
replaceFrom: number;
replaceTo: number;
insertText: string;
@@ -4,6 +4,37 @@ import { SessionController } from "./sessionController";
import { defaultApi, EmitSocket, emptyPage, FakeSocket, oldSession, runPendingAnimationFrames, status, workspace, type AppState, type SessionActivity, type SessionInfo } from "./sessionController.testSupport";
describe("SessionController live events", () => {
it("emits only final assistant text after agent.end", async () => {
const socket = new EmitSocket();
const replies: string[] = [];
let state: AppState = {
...initialAppState(),
selectedWorkspace: workspace,
selectedSession: oldSession,
sessions: [oldSession],
messages: [{ role: "assistant", parts: [{ type: "thinking", text: "private" }, { type: "text", text: "Final answer" }] }],
};
const api: typeof defaultApi = {
...defaultApi,
messages: () => Promise.resolve(emptyPage),
status: () => Promise.resolve(status(oldSession.id)),
streamSnapshot: () => Promise.resolve({ seq: 0, partial: null }),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket, onFinalAssistantResponse: ({ text }) => { replies.push(text); } },
);
await controller.selectSession(oldSession, { updateUrl: false });
state = { ...state, messages: [{ role: "assistant", parts: [{ type: "thinking", text: "private" }, { type: "text", text: "Final answer" }] }] };
socket.emit({ type: "agent.end" });
expect(replies).toEqual(["Final answer"]);
});
it("coalesces rapid status updates into a single state write per frame", () => {
const setStateCalls: Partial<AppState>[] = [];
let state: AppState = { ...initialAppState(), selectedSession: oldSession, sessions: [oldSession] };
@@ -298,4 +298,68 @@ describe("SessionController session_start dialog startup reachability", () => {
resolveBackendSession(harness);
await start;
});
it("drops a mid-startup status snapshot that lands after the readiness swap", async () => {
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } };
const resyncRequest = deferred<SessionStatus>();
let resyncIssued = false;
const harness = pendingStartController(state, {
status: (session) => {
// The first backend status call is the subscribe-time resync; hold it
// until after the swap. Later calls (the readiness join) answer fresh.
if (sessionLookupId(session) === BACKEND_SESSION_ID && !resyncIssued) {
resyncIssued = true;
return resyncRequest.promise;
}
return Promise.resolve(status(sessionLookupId(session)));
},
});
const { start, tempId } = beginPendingStart(harness);
reportBackendSessionId(harness, tempId);
harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-1") });
expect(harness.state.current.pendingDialogs).toEqual([dialog("dialog-1")]);
resolveBackendSession(harness);
await start;
await vi.waitFor(() => { expect(harness.state.current.selectedSession?.id).toBe(BACKEND_SESSION_ID); });
// The stale snapshot — issued before the swap and claiming dialog-1 is
// still open — must not clobber the real session's fresher state.
resyncRequest.resolve(statusWithDialogs(BACKEND_SESSION_ID, [dialog("dialog-1")]));
await Promise.resolve();
await Promise.resolve();
expect(harness.state.current.pendingDialogs).toEqual([]);
expect(harness.state.current.sessionStatuses[BACKEND_SESSION_ID]?.pendingDialogs ?? []).toEqual([]);
});
it("drops the dead card, closes the socket, and ignores late frames when the create fails mid-startup", async () => {
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } };
let answerCalled = false;
const harness = pendingStartController(state, {
answerDialog: () => {
answerCalled = true;
return Promise.resolve(closeResponse(status(BACKEND_SESSION_ID)));
},
});
const closeSpy = vi.spyOn(harness.socket, "close");
const { start, tempId } = beginPendingStart(harness);
reportBackendSessionId(harness, tempId);
harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-1") });
expect(harness.state.current.pendingDialogs).toEqual([dialog("dialog-1")]);
closeSpy.mockClear();
harness.startRequest.reject(new Error("create exploded"));
await start;
expect(harness.state.current.error).toBe("Failed to start session: create exploded");
expect(harness.state.current.pendingDialogs).toEqual([]);
expect(closeSpy).toHaveBeenCalled();
// Late frames from the dead session are dropped, and no answer can leave.
harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-2") });
expect(harness.state.current.pendingDialogs).toEqual([]);
await harness.controller.answerDialog("dialog-2", true);
expect(answerCalled).toBe(false);
});
});
@@ -52,6 +52,12 @@ export interface SelectedSessionReady {
session: SessionInfo;
}
export interface FinalAssistantResponse {
machineId: string;
sessionId: string;
text: string;
}
export interface SessionControllerDependencies {
api?: typeof defaultApi;
socket?: SessionEventSocket;
@@ -59,6 +65,7 @@ export interface SessionControllerDependencies {
notifications?: SessionNotificationSessionBridge;
replacePromptEditorText?: (replacement: PromptEditorTextReplacement) => void | Promise<void>;
onSelectedSessionReady?: (selection: SelectedSessionReady) => void;
onFinalAssistantResponse?: (response: FinalAssistantResponse) => void;
}
interface BulkSessionMutationResult {
@@ -111,6 +118,7 @@ export class SessionController {
private readonly notifications: SessionNotificationSessionBridge | undefined;
private readonly replacePromptEditorText: SessionControllerDependencies["replacePromptEditorText"];
private readonly onSelectedSessionReady: SessionControllerDependencies["onSelectedSessionReady"];
private readonly onFinalAssistantResponse: SessionControllerDependencies["onFinalAssistantResponse"];
private selectionSeq = 0;
private disposed = false;
// Join-time stream watermark for the selected session. `seq` is the
@@ -142,6 +150,7 @@ export class SessionController {
this.notifications = deps.notifications;
this.replacePromptEditorText = deps.replacePromptEditorText;
this.onSelectedSessionReady = deps.onSelectedSessionReady;
this.onFinalAssistantResponse = deps.onFinalAssistantResponse;
}
applyGlobalEvent(event: GlobalSessionEvent): void {
@@ -1214,9 +1223,15 @@ export class SessionController {
const pending = this.pendingSessionStarts.get(tempId);
if (pending === undefined) return;
this.pendingSessionStarts.delete(tempId);
const wasDiscarded = pending.discarded;
// The pending start is dead: stop routing its dialog frames (a card on the
// failed row could never be answered) and drop the early-subscribed socket
// so it stops reconnecting against a session that may not exist.
pending.discarded = true;
if (this.getState().selectedSession?.id === tempId) this.socket.close();
const releasedCreatedSessions = this.takeSuppressedCreatedSessionsFor(pending.cwd, pending.machineId);
const isCurrentPendingStart = this.isCurrentPendingStart(pending);
if (pending.discarded || !isCurrentPendingStart) {
if (wasDiscarded || !isCurrentPendingStart) {
if (isCurrentPendingStart) this.applyReleasedCreatedSessions(releasedCreatedSessions, pending.machineId);
return;
}
@@ -1228,6 +1243,9 @@ export class SessionController {
sessions: hasPendingRow ? state.sessions : [pending.session, ...state.sessions],
sessionActivities: { ...state.sessionActivities, [tempId]: activity },
activity: state.selectedSession?.id === tempId ? activity : state.activity,
// Open cards on the failed row are dead: the create is gone, so no
// answer could ever reach the daemon. Settled outcomes stay as history.
...(state.selectedSession?.id === tempId ? { pendingDialogs: [] } : {}),
error: `Failed to start session: ${message}`,
});
this.applyReleasedCreatedSessions(releasedCreatedSessions, pending.machineId);
@@ -1480,6 +1498,7 @@ export class SessionController {
}
this.flushPendingUpdates();
if (event.type === "agent.end") this.emitFinalAssistantResponse();
// Ask frames are applied after the buffered status they were published with,
// so the card follows the daemon's own open/close order.
if (event.type === "ask.opened") {
@@ -1585,8 +1604,11 @@ export class SessionController {
if (backendSessionId === undefined) return;
void this.api.status({ id: backendSessionId, cwd: pending.cwd }, pending.machineId).then(
(status) => {
this.applyStatus(status);
// Guard before applying: this unordered snapshot can land after the
// readiness swap made the real session selected, and a stale replace
// must not clobber the socket's fresher dialog state.
if (pending.discarded || this.getState().selectedSession?.id !== pending.tempId) return;
this.applyStatus(status);
const state = this.getState();
const knownIds = new Set<string>([
...state.pendingDialogs.map((pendingDialog) => pendingDialog.dialogId),
@@ -1668,6 +1690,14 @@ export class SessionController {
}
}
private emitFinalAssistantResponse(): void {
const session = this.getState().selectedSession;
if (session === undefined) return;
const text = finalAssistantReply(this.getState().messages);
if (text === "") return;
this.onFinalAssistantResponse?.({ machineId: selectedMachineId(this.getState()), sessionId: session.id, text });
}
private clearPendingUpdates(): void {
this.pendingTranscriptEvents = [];
this.pendingStatusBySession.clear();
@@ -1689,6 +1719,11 @@ export class SessionController {
}
}
function finalAssistantReply(messages: AppState["messages"]): string {
const message = [...messages].reverse().find((line) => line.role === "assistant");
return message?.parts.filter((part) => part.type === "text").map((part) => part.text).join("\n").trim() ?? "";
}
function omitSessionActivity(activities: Record<string, SessionActivity>, sessionId: string): Record<string, SessionActivity> {
return omitKey(activities, sessionId);
}
+7
View File
@@ -31,4 +31,11 @@ describe("input mode helpers", () => {
expect(inputModeForDraft("open !@\"vendor/file.ts")).toEqual({ kind: "file" });
expect(inputModeForDraft("open \"src/main.ts")).toEqual({ kind: "normal" });
});
it("collapses # model completion tokens to model mode", () => {
expect(inputModeForDraft("#anthropic/claude-opus")).toEqual({ kind: "model" });
expect(inputModeForDraft("use #gpt-5.2")).toEqual({ kind: "model" });
expect(inputModeForDraft("use #gpt-5.2 please")).toEqual({ kind: "normal" });
expect(inputModeForDraft("# ")).toEqual({ kind: "normal" });
});
});
+4 -1
View File
@@ -4,6 +4,7 @@ export type InputMode =
| { kind: "normal" }
| { kind: "command" }
| { kind: "file" }
| { kind: "model" }
| { kind: "shell"; excludeFromContext: boolean };
export function inputModeForDraft(draft: string): InputMode {
@@ -11,7 +12,9 @@ export function inputModeForDraft(draft: string): InputMode {
if (trimmed.startsWith("!@")) return { kind: "file" };
if (trimmed.startsWith("!")) return { kind: "shell", excludeFromContext: trimmed.startsWith("!!") };
if (currentToken(draft).startsWith("/")) return { kind: "command" };
if (detectPromptCompletionTrigger(draft)?.kind === "file") return { kind: "file" };
const trigger = detectPromptCompletionTrigger(draft);
if (trigger?.kind === "file") return { kind: "file" };
if (trigger?.kind === "model") return { kind: "model" };
return { kind: "normal" };
}
+69 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { detectPromptCompletionTrigger, fileCompletionInsertText } from "./promptCompletions";
import type { SessionModel } from "../../shared/apiTypes";
import { detectPromptCompletionTrigger, fileCompletionInsertText, modelCompletionChoices } from "./promptCompletions";
describe("detectPromptCompletionTrigger", () => {
it("keeps all-file suggestions active when an @ space query contains spaces", () => {
@@ -54,6 +55,73 @@ describe("detectPromptCompletionTrigger", () => {
});
expect(detectPromptCompletionTrigger("/model")).toEqual({ kind: "command", query: "model", from: 0, to: 6 });
});
it("detects model queries for tokens starting with #", () => {
expect(detectPromptCompletionTrigger("#")).toEqual({ kind: "model", query: "", from: 0, to: 1 });
expect(detectPromptCompletionTrigger("use #anthropic/claude-opus")).toEqual({ kind: "model", query: "anthropic/claude-opus", from: 4, to: 26 });
});
it("detects model queries at any cursor position but not inside other tokens or quotes", () => {
expect(detectPromptCompletionTrigger("use #claude now", 10)).toEqual({ kind: "model", query: "claud", from: 4, to: 10 });
expect(detectPromptCompletionTrigger("say hello#world")).toBeUndefined();
expect(detectPromptCompletionTrigger('say "#claude')).toBeUndefined();
});
it("shows model completion for a markdown header only on the bare # keystroke", () => {
expect(detectPromptCompletionTrigger("#")).toEqual({ kind: "model", query: "", from: 0, to: 1 });
expect(detectPromptCompletionTrigger("# ")).toBeUndefined();
expect(detectPromptCompletionTrigger("# Title")).toBeUndefined();
});
});
describe("modelCompletionChoices", () => {
const models: SessionModel[] = [
{ provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus 4.5" },
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" },
{ provider: "openai", id: "gpt-5.2", name: "GPT-5.2" },
{ provider: "google", id: "gemini-3-pro" },
];
it("lists all models for an empty query, mapped to #provider/id insert texts", () => {
expect(modelCompletionChoices(models, "")).toEqual([
{ insertText: "#anthropic/claude-opus-4-5", detail: "anthropic", description: "Claude Opus 4.5" },
{ insertText: "#anthropic/claude-sonnet-4-5", detail: "anthropic", description: "Claude Sonnet 4.5" },
{ insertText: "#openai/gpt-5.2", detail: "openai", description: "GPT-5.2" },
{ insertText: "#google/gemini-3-pro", detail: "google" },
]);
});
it("filters case-insensitively across provider/id, id, and display name", () => {
expect(modelCompletionChoices(models, "OPUS-4")).toEqual([
{ insertText: "#anthropic/claude-opus-4-5", detail: "anthropic", description: "Claude Opus 4.5" },
]);
expect(modelCompletionChoices(models, "openai")).toEqual([
{ insertText: "#openai/gpt-5.2", detail: "openai", description: "GPT-5.2" },
]);
expect(modelCompletionChoices(models, "sonnet 4.5")).toEqual([
{ insertText: "#anthropic/claude-sonnet-4-5", detail: "anthropic", description: "Claude Sonnet 4.5" },
]);
});
it("omits the description when the display name matches the id", () => {
expect(modelCompletionChoices([{ provider: "ollama", id: "qwen3", name: "qwen3" }], "")).toEqual([
{ insertText: "#ollama/qwen3", detail: "ollama" },
]);
});
it("skips models without a provider or id since they cannot form a #provider/id reference", () => {
expect(modelCompletionChoices([{ id: "orphan" }, { provider: "ghost" }, { provider: "openai", id: "gpt-5.2" }], "")).toEqual([
{ insertText: "#openai/gpt-5.2", detail: "openai" },
]);
});
it("caps the list at 12 models, preserving server order", () => {
const many = Array.from({ length: 20 }, (_, index) => ({ provider: "p", id: `m${String(index)}` }));
const choices = modelCompletionChoices(many, "");
expect(choices).toHaveLength(12);
expect(choices[0]?.insertText).toBe("#p/m0");
expect(choices[11]?.insertText).toBe("#p/m11");
});
});
describe("fileCompletionInsertText", () => {
+41 -1
View File
@@ -1,6 +1,9 @@
import type { SessionModel } from "../../shared/apiTypes";
export type PromptCompletionTrigger =
| { kind: "command"; query: string; from: number; to: number }
| { kind: "file"; query: string; from: number; to: number; fileScope?: "tracked" | "all" | undefined; allPrefix?: "@ " | "!@" | undefined; quoted?: boolean };
| { kind: "file"; query: string; from: number; to: number; fileScope?: "tracked" | "all" | undefined; allPrefix?: "@ " | "!@" | undefined; quoted?: boolean }
| { kind: "model"; query: string; from: number; to: number };
export function detectPromptCompletionTrigger(draft: string, cursor = draft.length): PromptCompletionTrigger | undefined {
const beforeCursor = draft.slice(0, cursor);
@@ -17,9 +20,46 @@ export function detectPromptCompletionTrigger(draft: string, cursor = draft.leng
if (token.startsWith("/") && tokenStart === 0) return { kind: "command", query: token.slice(1), from: tokenStart, to: cursor };
if (token.startsWith("!@")) return { kind: "file", query: token.slice(2), from: tokenStart, to: cursor, fileScope: "all", allPrefix: "!@" };
if (token.startsWith("@")) return { kind: "file", query: token.slice(1), from: tokenStart, to: cursor, fileScope: "tracked" };
if (token.startsWith("#")) return { kind: "model", query: token.slice(1), from: tokenStart, to: cursor };
return undefined;
}
export interface ModelCompletionChoice {
insertText: string;
detail: string;
description?: string;
}
const MODEL_COMPLETION_LIMIT = 12;
export function modelCompletionChoices(models: readonly SessionModel[], query: string): ModelCompletionChoice[] {
const needle = query.toLowerCase();
const choices: ModelCompletionChoice[] = [];
for (const model of models) {
// A completion must produce a strict provider/model-id reference, so models
// missing either half of the identity can never be inserted.
if (!hasQualifiedModelId(model)) continue;
if (!modelMatchesQuery(model, needle)) continue;
choices.push({
insertText: `#${model.provider}/${model.id}`,
detail: model.provider,
...(model.name !== undefined && model.name !== "" && model.name !== model.id ? { description: model.name } : {}),
});
if (choices.length >= MODEL_COMPLETION_LIMIT) break;
}
return choices;
}
function hasQualifiedModelId(model: SessionModel): model is SessionModel & { provider: string; id: string } {
return typeof model.provider === "string" && model.provider !== "" && typeof model.id === "string" && model.id !== "";
}
function modelMatchesQuery(model: SessionModel & { provider: string; id: string }, needle: string): boolean {
return `${model.provider}/${model.id}`.toLowerCase().includes(needle)
|| model.id.toLowerCase().includes(needle)
|| (model.name?.toLowerCase().includes(needle) ?? false);
}
export function fileCompletionInsertText(path: string, quoted: boolean, allPrefix?: "@ " | "!@"): string {
const prefix = allPrefix ?? "@";
if (!quoted && !path.includes(" ")) return `${prefix}${path}`;
+2 -1
View File
@@ -1,4 +1,4 @@
export type SettingsSection = "general" | "sessiond" | "packages" | "plugins" | "shortcuts";
export type SettingsSection = "general" | "sessiond" | "packages" | "plugins" | "shortcuts" | "azure-speech";
export function readSettingsSection(): SettingsSection | undefined {
return parseSettingsSection(new URLSearchParams(window.location.search).get("settings"));
@@ -21,5 +21,6 @@ export function parseSettingsSection(value: string | null): SettingsSection | un
if (value === "packages" || value === "pi-packages") return "packages";
if (value === "plugins") return "plugins";
if (value === "shortcuts" || value === "keyboard" || value === "keyboard-shortcuts") return "shortcuts";
if (value === "azure-speech" || value === "voice") return "azure-speech";
return undefined;
}
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { transitionVoiceRecognition } from "./voiceRecognitionLifecycle";
describe("voice recognition lifecycle", () => {
it("keeps the microphone allocated while a final turn waits for TTS", () => {
expect(transitionVoiceRecognition("idle", "start")).toBe("listening");
expect(transitionVoiceRecognition("listening", "final")).toBe("paused");
expect(transitionVoiceRecognition("paused", "resume")).toBe("listening");
});
it("ignores duplicate finals until the response resumes listening", () => {
expect(transitionVoiceRecognition("paused", "final")).toBe("paused");
});
it("stops from every active phase", () => {
expect(transitionVoiceRecognition("listening", "stop")).toBe("idle");
expect(transitionVoiceRecognition("paused", "stop")).toBe("idle");
});
});
@@ -0,0 +1,14 @@
export type VoiceRecognitionPhase = "idle" | "listening" | "paused";
export type VoiceRecognitionEvent = "start" | "final" | "resume" | "stop";
/**
* The microphone remains allocated across paused turns. A final result is
* accepted once only; subsequent events are ignored until TTS resumes it.
*/
export function transitionVoiceRecognition(phase: VoiceRecognitionPhase, event: VoiceRecognitionEvent): VoiceRecognitionPhase {
if (event === "stop") return "idle";
if (event === "start" && phase === "idle") return "listening";
if (event === "final" && phase === "listening") return "paused";
if (event === "resume" && phase === "paused") return "listening";
return phase;
}
+21 -2
View File
@@ -2,9 +2,12 @@ import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyServerOptions } from "fastify";
import type { ServerOptions as HttpsServerOptions } from "node:https";
import fastifyCompress from "@fastify/compress";
import fastifyStatic from "@fastify/static";
import fastifyWebsocket from "@fastify/websocket";
import fastifySwagger from "@fastify/swagger";
import fastifySwaggerUi from "@fastify/swagger-ui";
import { ProjectStore } from "./storage/projectStore.js";
import { ProjectService } from "./projects/projectService.js";
import { WorkspaceService } from "./workspaces/workspaceService.js";
@@ -20,6 +23,9 @@ import { registerGitRoutes } from "./gitRoutes.js";
import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js";
import { registerWorkspaceDeletionRoutes } from "./workspaces/workspaceDeletionRoutes.js";
import { createFilePiWebConfigService, registerConfigRoutes, registerLocalMachineConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
import { registerAzureSpeechRoutes } from "./azureSpeechRoutes.js";
import { voiceApiOpenApi } from "./voiceApiOpenApi.js";
import { registerVoiceApiRoutes } from "./voiceApi.js";
import { PiWebPluginService } from "./piWebPluginService.js";
import { createActiveProfilePiPackageService, type PiPackageService } from "./piPackageService.js";
import { registerPiPackageRoutes } from "./piPackageRoutes.js";
@@ -51,6 +57,7 @@ export interface AppDependencies {
logger?: FastifyServerOptions["logger"];
/** Maximum accepted HTTP request body size in bytes. */
bodyLimit?: number;
https?: HttpsServerOptions;
}
interface LocalProjectRouteOptions {
@@ -151,8 +158,12 @@ async function withProfileDependency<T>(reply: FastifyReply, operation: () => Pr
}
}
export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInstance> {
const app = Fastify({ logger: deps.logger ?? true, ...(deps.bodyLimit === undefined ? {} : { bodyLimit: deps.bodyLimit }) });
export async function buildApp(deps: AppDependencies = {}) {
const options = {
logger: deps.logger ?? true,
...(deps.bodyLimit === undefined ? {} : { bodyLimit: deps.bodyLimit }),
};
const app: FastifyInstance = deps.https === undefined ? Fastify(options) : Fastify({ ...options, https: deps.https });
// Vite proxies development API requests here, while production and machine-scoped
// API requests already terminate here, so this is the shared browser HTTP edge.
await app.register(fastifyCompress, {
@@ -161,6 +172,11 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
threshold: 1024,
});
await app.register(fastifyWebsocket);
await app.register(fastifySwagger, { mode: "static", specification: { document: voiceApiOpenApi } });
await app.register(fastifySwaggerUi, {
routePrefix: "/api/v1/voice/docs",
uiConfig: { docExpansion: "list", persistAuthorization: true },
});
const projects = deps.projects ?? new ProjectService(new ProjectStore());
const workspaces = deps.workspaces ?? new WorkspaceService();
@@ -214,6 +230,9 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
const invalidatingConfigService = invalidatePiWebStatusOnWrite(configService, piWebStatusCache);
registerConfigRoutes(app, invalidatingConfigService);
registerLocalMachineConfigRoutes(app, invalidatingConfigService);
registerAzureSpeechRoutes(app);
registerAzureSpeechRoutes(app, undefined, "/api/machines/local");
registerVoiceApiRoutes(app, { projects, workspaces, daemon: sessionDaemon });
registerMachineRoutes(app, machines);
registerMachinePluginProxyRoutes(app, machines);
+79
View File
@@ -0,0 +1,79 @@
import { describe, expect, it, vi } from "vitest";
import Fastify from "fastify";
import {
registerAzureSpeechRoutes,
type AzureSpeechService,
} from "./azureSpeechRoutes.js";
function service(): AzureSpeechService {
return {
settings: vi.fn(() => ({
region: "eastus",
voice: "en-US-Andrew:DragonHDLatestNeural",
hasApiKey: true,
})),
update: vi.fn(() => ({
region: "eastus",
voice: "en-US-Andrew:DragonHDLatestNeural",
hasApiKey: true,
})),
token: vi.fn(() =>
Promise.resolve({
token: "short-lived-token",
region: "eastus",
voice: "en-US-Andrew:DragonHDLatestNeural",
})
),
};
}
describe("Azure Speech routes", () => {
it("returns redacted settings and accepts a write-only API key", async () => {
const app = Fastify();
const azure = service();
registerAzureSpeechRoutes(app, azure);
const get = await app.inject({ method: "GET", url: "/api/azure-speech" });
expect(get.statusCode).toBe(200);
expect(get.json()).toEqual({
region: "eastus",
voice: "en-US-Andrew:DragonHDLatestNeural",
hasApiKey: true,
});
expect(get.body).not.toContain("apiKey");
const put = await app.inject({
method: "PUT",
url: "/api/azure-speech",
payload: {
region: "eastus",
voice: "en-US-Andrew:DragonHDLatestNeural",
apiKey: "a".repeat(32),
},
});
expect(put.statusCode).toBe(200);
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(azure.update).toHaveBeenCalledWith({
region: "eastus",
voice: "en-US-Andrew:DragonHDLatestNeural",
apiKey: "a".repeat(32),
});
expect(put.body).not.toContain("apiKey");
await app.close();
});
it("rejects malformed settings before they reach the service", async () => {
const app = Fastify();
const azure = service();
registerAzureSpeechRoutes(app, azure);
const response = await app.inject({
method: "PUT",
url: "/api/azure-speech",
payload: { region: "eastus!", voice: "voice" },
});
expect(response.statusCode).toBe(400);
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(azure.update).not.toHaveBeenCalled();
await app.close();
});
});
+144
View File
@@ -0,0 +1,144 @@
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import type { FastifyInstance } from "fastify";
import { piWebConfigPath } from "../config.js";
import type { AzureSpeechSettings, AzureSpeechSettingsUpdate, AzureSpeechToken } from "../shared/apiTypes.js";
interface AzureSpeechSecret {
region: string;
voice: string;
apiKey?: string;
}
/** Server-only Azure credentials. Never expose this shape through an HTTP route. */
export interface AzureSpeechServerSettings { region: string; voice: string; apiKey: string }
export function readAzureSpeechServerSettings(path = join(dirname(piWebConfigPath()), "azure-speech.json")): AzureSpeechServerSettings {
const secret = readSecret(path);
if (secret.apiKey === undefined || secret.region === "") throw new AzureSpeechConfigurationError("Azure Speech is not configured. Add a Speech key and region in Settings → Azure Speech.");
return { region: secret.region, voice: secret.voice, apiKey: secret.apiKey };
}
export interface AzureSpeechService {
settings(): AzureSpeechSettings;
update(input: AzureSpeechSettingsUpdate): AzureSpeechSettings;
token(): Promise<AzureSpeechToken>;
}
export function createFileAzureSpeechService(): AzureSpeechService {
const path = join(dirname(piWebConfigPath()), "azure-speech.json");
return {
settings: () => publicSettings(readSecret(path)),
update: (input) => {
const current = readSecret(path);
const next: AzureSpeechSecret = {
region: requireRegion(input.region),
voice: requireVoice(input.voice),
...(input.apiKey === undefined ? (current.apiKey === undefined ? {} : { apiKey: current.apiKey }) : input.apiKey === "" ? {} : { apiKey: requireApiKey(input.apiKey) }),
};
writeSecret(path, next);
return publicSettings(next);
},
token: async () => {
const secret = readSecret(path);
if (secret.apiKey === undefined || secret.region === "") throw new AzureSpeechConfigurationError("Azure Speech is not configured. Add a Speech key and region in Settings → Azure Speech.");
const response = await fetch(`https://${encodeURIComponent(secret.region)}.api.cognitive.microsoft.com/sts/v1.0/issueToken`, {
method: "POST",
headers: { "Ocp-Apim-Subscription-Key": secret.apiKey },
// Let the runtime calculate Content-Length. PI's installed Undici dispatcher
// rejects a manually supplied header, while Azure requires a zero-length POST body.
body: "",
});
if (!response.ok) throw new AzureSpeechTokenError(`Azure Speech token request failed (${String(response.status)}). Check the key and region.`);
const token = (await response.text()).trim();
if (token === "") throw new AzureSpeechTokenError("Azure Speech returned an empty authorization token.");
return { token, region: secret.region, voice: secret.voice };
},
};
}
export function registerAzureSpeechRoutes(app: FastifyInstance, service: AzureSpeechService = createFileAzureSpeechService(), prefix = "/api"): void {
app.get(`${prefix}/azure-speech`, async (_request, reply) => {
try {
return service.settings();
} catch (error) {
return reply.code(500).send({ error: errorMessage(error) });
}
});
app.put<{ Body: unknown }>(`${prefix}/azure-speech`, async (request, reply) => {
try {
return service.update(parseSettingsUpdate(request.body));
} catch (error) {
return reply.code(400).send({ error: errorMessage(error) });
}
});
app.post(`${prefix}/azure-speech/token`, async (_request, reply) => {
try {
return await service.token();
} catch (error) {
const status = error instanceof AzureSpeechConfigurationError ? 400 : error instanceof AzureSpeechTokenError ? 502 : 500;
app.log.error({ err: error }, "Azure Speech token request failed");
return reply.code(status).send({ error: errorMessage(error) });
}
});
}
function readSecret(path: string): AzureSpeechSecret {
if (!existsSync(path)) return { region: "eastus", voice: "" };
const value: unknown = JSON.parse(readFileSync(path, "utf8"));
if (!isRecord(value)) throw new Error(`Azure Speech settings must be a JSON object: ${path}`);
return {
region: value["region"] === undefined ? "eastus" : requireRegion(value["region"]),
voice: value["voice"] === undefined ? "" : requireVoice(value["voice"]),
...(value["apiKey"] === undefined ? {} : { apiKey: requireApiKey(value["apiKey"]) }),
};
}
function writeSecret(path: string, secret: AzureSpeechSecret): void {
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
writeFileSync(path, `${JSON.stringify(secret, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
chmodSync(path, 0o600);
}
function publicSettings(secret: AzureSpeechSecret): AzureSpeechSettings {
return { region: secret.region, voice: secret.voice, hasApiKey: secret.apiKey !== undefined };
}
function parseSettingsUpdate(value: unknown): AzureSpeechSettingsUpdate {
if (!isRecord(value)) throw new Error("Azure Speech settings update must be an object");
return {
region: requireRegion(value["region"]),
voice: requireVoice(value["voice"]),
...(value["apiKey"] === undefined ? {} : { apiKey: typeof value["apiKey"] === "string" ? value["apiKey"] : fail("Azure Speech API key must be a string") }),
};
}
function requireRegion(value: unknown): string {
if (typeof value !== "string" || !/^[a-z0-9-]{2,64}$/i.test(value.trim())) throw new Error("Azure Speech region must contain only letters, digits, and hyphens");
return value.trim().toLowerCase();
}
function requireVoice(value: unknown): string {
if (typeof value !== "string" || value.trim().length > 256) throw new Error("Azure Speech voice must be a string up to 256 characters");
return value.trim();
}
function requireApiKey(value: unknown): string {
if (typeof value !== "string" || value.trim().length < 16 || value.trim().length > 512) throw new Error("Azure Speech API key must be between 16 and 512 characters");
return value.trim();
}
function fail(message: string): never {
throw new Error(message);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
class AzureSpeechConfigurationError extends Error {}
class AzureSpeechTokenError extends Error {}
+15 -1
View File
@@ -1,7 +1,21 @@
#!/usr/bin/env node
import { readFileSync } from "node:fs";
import { effectivePiWebConfig, maxUploadBytes } from "../config.js";
import { buildApp } from "./app.js";
const { config } = effectivePiWebConfig();
const app = await buildApp({ bodyLimit: maxUploadBytes(process.env, config) });
const app = await buildApp({
bodyLimit: maxUploadBytes(process.env, config),
...tlsOptions(process.env),
});
await app.listen({ port: config.port ?? 8504, host: config.host ?? "127.0.0.1" });
function tlsOptions(env: NodeJS.ProcessEnv): { https?: { key: Buffer; cert: Buffer } } {
const keyPath = env["PI_WEB_TLS_KEY"];
const certPath = env["PI_WEB_TLS_CERT"];
if ((keyPath === undefined || keyPath === "") && (certPath === undefined || certPath === "")) return {};
if (keyPath === undefined || keyPath === "" || certPath === undefined || certPath === "") {
throw new Error("Set both PI_WEB_TLS_KEY and PI_WEB_TLS_CERT to enable HTTPS.");
}
return { https: { key: readFileSync(keyPath), cert: readFileSync(certPath) } };
}
@@ -1,6 +1,7 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createPiWebCustomToolDefinitions, sessionAllowsDelegationTools, type PiSessionManager } from "./piSessionService.js";
import type { SubsessionToolDeps } from "./spawnSubsessionTool.js";
@@ -14,13 +15,14 @@ afterEach(async () => {
function delegationDeps() {
const spawn = vi.fn(() => Promise.resolve({ sessionId: "independent-1", cwd: "/workspace" }));
const subsessionSpawn = vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/workspace" }));
const subsessions: SubsessionToolDeps = {
spawn: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/workspace" })),
spawn: subsessionSpawn,
list: vi.fn(() => Promise.resolve([])),
check: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/workspace", status: "idle" as const, finalText: "", messageCount: 0 })),
read: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/workspace", status: "idle" as const, entries: [], total: 0, matched: 0, start: 0, hasMore: false })),
};
return { spawn, subsessions };
return { spawn, subsessions, subsessionSpawn };
}
function toolNames(definitions: ReturnType<typeof createPiWebCustomToolDefinitions>): string[] {
@@ -35,6 +37,20 @@ function manager(id: string, file: string | undefined, entries: readonly unknown
});
}
const dispatchModel = { provider: "anthropic", id: "claude-sonnet" };
function ctxFor(sessionId: string, sessionFile: string | undefined, model?: unknown): ExtensionContext {
// The delegation tools only read sessionManager and model from the context.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test stub with the minimal surface the tools read.
return { sessionManager: manager(sessionId, sessionFile), ...(model === undefined ? {} : { model }) } as unknown as ExtensionContext;
}
function findTool(definitions: ReturnType<typeof createPiWebCustomToolDefinitions>, name: string) {
const tool = definitions.find((definition) => definition.name === name);
if (tool === undefined) throw new Error(`missing tool ${name}`);
return tool;
}
describe("delegation tool capability boundary", () => {
it("provides every globally enabled delegation tool to unrestricted sessions", () => {
const { spawn, subsessions } = delegationDeps();
@@ -63,6 +79,39 @@ describe("delegation tool capability boundary", () => {
expect(toolNames(createPiWebCustomToolDefinitions("/workspace", false, spawn, subsessions))).toEqual(["edit"]);
});
it("wires the dispatching session identity, inherited model, and model spec into spawn_session", async () => {
const { spawn, subsessions } = delegationDeps();
const spawnTool = findTool(createPiWebCustomToolDefinitions("/workspace", true, spawn, subsessions), "spawn_session");
await spawnTool.execute("call-1", { prompt: "go", model: "openai/gpt-5" }, undefined, undefined, ctxFor("spawner-7", "/sessions/spawner-7.jsonl", dispatchModel));
expect(spawn).toHaveBeenCalledWith({
spawningCwd: "/workspace",
spawningSessionId: "spawner-7",
prompt: "go",
cwd: undefined,
model: dispatchModel,
modelSpec: "openai/gpt-5",
});
});
it("wires the parent identity, inherited model, and model spec into spawn_subsession", async () => {
const { spawn, subsessions, subsessionSpawn } = delegationDeps();
const spawnTool = findTool(createPiWebCustomToolDefinitions("/workspace", true, spawn, subsessions), "spawn_subsession");
await spawnTool.execute("call-2", { prompt: "go", model: "openai/gpt-5" }, undefined, undefined, ctxFor("parent-9", "/sessions/parent-9.jsonl", dispatchModel));
expect(subsessionSpawn).toHaveBeenCalledWith({
spawningCwd: "/workspace",
parentSessionId: "parent-9",
parentSessionFile: "/sessions/parent-9.jsonl",
prompt: "go",
cwd: undefined,
model: dispatchModel,
modelSpec: "openai/gpt-5",
});
});
it.each(["human-created", "spawn_session-created"])("allows delegation for a %s session without tracked-child provenance", async () => {
const sessionManager = manager("session-1", undefined);
const open = vi.fn(() => { throw new Error("no parent session should be opened"); });
@@ -597,4 +597,37 @@ describe("PiSessionService session_start dialog startup reachability", () => {
expect(confirmAnswers).toEqual([false]);
await service.dispose();
});
it("dispose settles a startup-parked dialog instead of blocking behind its timeout", async () => {
const { service, store, events, confirmAnswers } = startupDialogService();
// The open flow registers in pendingSessionOpens, which dispose awaits:
// without settling the dialog first, disposal would ride its timeout.
const opening = service.messages(sessionRef(ACTIVE_SESSION_ID));
await parkOnStartupDialog(store);
await service.dispose();
expect(confirmAnswers).toEqual([false]);
const closedEvents = dialogEvents(events).filter(({ event }) => event.type === "dialog.closed");
expect(closedEvents).toHaveLength(1);
expect(closedEvents[0]?.event).toMatchObject({ dialogId: "dialog-1", reason: "session-ended" });
// The released open completed inside dispose's awaited window; the late
// messages read neither hangs nor rejects the test run.
await Promise.allSettled([opening]);
});
it("closing a session whose open is parked on a session_start dialog settles the dialog first", async () => {
const { service, store, events, confirmAnswers } = startupDialogService();
const opening = service.messages(sessionRef(ACTIVE_SESSION_ID));
await parkOnStartupDialog(store);
await service.stop(ACTIVE_SESSION_ID);
expect(confirmAnswers).toEqual([false]);
const closedEvents = dialogEvents(events).filter(({ event }) => event.type === "dialog.closed");
expect(closedEvents).toHaveLength(1);
expect(closedEvents[0]?.event).toMatchObject({ dialogId: "dialog-1", reason: "session-ended" });
await Promise.allSettled([opening]);
await service.dispose();
});
});
@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import { PiSessionService } from "./piSessionService.js";
import { CapturingSessionEventHub, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, testModelRuntime } from "./piSessionService.testSupport.js";
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
describe("PiSessionService", () => {
describe("assistant thinking-level attribution", () => {
function messagesService(branch: unknown[], patch: Parameters<typeof fakeRuntime>[1] = {}) {
const fake = fakeRuntime("session-1", {
sessionFile: "/tmp/session-1.jsonl",
sessionManager: fakeSessionManager("/workspace", { getBranch: () => branch }),
...patch,
});
const events = new CapturingSessionEventHub();
const service = new PiSessionService(events, {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("session-1")]),
heartbeatIntervalMs: 60_000,
});
return { fake, service, events };
}
it("annotates paged assistant messages with the thinking level in effect from branch entries", async () => {
const branch = [
{ type: "message", message: { role: "user", content: [{ type: "text", text: "hi" }] } },
{ type: "message", message: { role: "assistant", provider: "openai", model: "gpt-4.1", content: [{ type: "text", text: "before any entry" }] } },
{ type: "thinking_level_change", thinkingLevel: "medium" },
{ type: "message", message: { role: "assistant", provider: "openai", model: "gpt-4.1", content: [{ type: "text", text: "first answer" }] } },
{ type: "thinking_level_change", thinkingLevel: "max" },
{ type: "message", message: { role: "assistant", provider: "openai", model: "gpt-4.1", content: [{ type: "text", text: "second answer" }] } },
{ type: "thinking_level_change", thinkingLevel: "off" },
{ type: "message", message: { role: "assistant", provider: "openai", model: "gpt-4.1", content: [{ type: "text", text: "unthinking answer" }] } },
{ type: "message", message: { role: "toolResult", toolName: "bash", content: [{ type: "text", text: "done" }] } },
];
const { service } = messagesService(branch);
const messages = await service.messages(sessionRef("session-1"));
expect(messages).toEqual([
{ role: "user", content: [{ type: "text", text: "hi" }] },
{ role: "assistant", provider: "openai", model: "gpt-4.1", content: [{ type: "text", text: "before any entry" }] },
{ role: "assistant", provider: "openai", model: "gpt-4.1", content: [{ type: "text", text: "first answer" }], thinkingLevel: "medium" },
{ role: "assistant", provider: "openai", model: "gpt-4.1", content: [{ type: "text", text: "second answer" }], thinkingLevel: "max" },
{ role: "assistant", provider: "openai", model: "gpt-4.1", content: [{ type: "text", text: "unthinking answer" }] },
{ role: "toolResult", toolName: "bash", content: [{ type: "text", text: "done" }] },
]);
await service.dispose();
});
it("annotates live assistant message.end events with the session's current thinking level", async () => {
const { fake, service, events } = messagesService([], { thinkingLevel: "high" });
await service.status(sessionRef("session-1")); // bring the session online so it publishes events
fake.emit({ type: "message_end", message: { role: "assistant", provider: "openai", model: "gpt-4.1", content: [{ type: "text", text: "answer" }] } });
fake.emit({ type: "message_end", message: { role: "user", content: [{ type: "text", text: "next" }] } });
const messageEnds = events.sessionEvents.map(({ event }) => event).filter((event) => event.type === "message.end");
expect(messageEnds).toEqual([
{ type: "message.end", message: { role: "assistant", provider: "openai", model: "gpt-4.1", content: [{ type: "text", text: "answer" }], thinkingLevel: "high" } },
{ type: "message.end", message: { role: "user", content: [{ type: "text", text: "next" }] } },
]);
await service.dispose();
});
it("forwards Pi's native agent_settled event without inferring it from agent_end", async () => {
const { fake, service, events } = messagesService([]);
await service.status(sessionRef("session-1"));
fake.emit({ type: "agent_end" });
await new Promise<void>((resolve) => setImmediate(resolve));
expect(events.sessionEvents.map(({ event }) => event).filter((event) => event.type === "agent.settled")).toEqual([]);
fake.emit({ type: "agent_settled" });
expect(events.sessionEvents.map(({ event }) => event).filter((event) => event.type === "agent.settled")).toEqual([
{ type: "agent.settled" },
]);
await service.dispose();
});
it("annotates the join-time stream snapshot partial with the current thinking level", async () => {
const streamingMessage = {
role: "assistant",
provider: "openai",
model: "gpt-4.1",
content: [{ type: "thinking", thinking: "hmm", thinkingSignature: "provider-signature" }],
};
const { service } = messagesService([], { thinkingLevel: "xhigh", state: { streamingMessage } });
const snapshot = await service.streamSnapshot(sessionRef("session-1"));
expect(snapshot.partial).toEqual({
role: "assistant",
provider: "openai",
model: "gpt-4.1",
content: [{ type: "thinking", thinking: "hmm" }],
thinkingLevel: "xhigh",
});
await service.dispose();
});
});
});
@@ -4,6 +4,7 @@ import type { SpawnTargetDecision } from "./spawnTargetResolver.js";
import { CapturingSessionEventHub, fakeRuntime, runtimeCreator, sessionGateway, testModel, testModelRuntime, type RuntimeCreator } from "./piSessionService.testSupport.js";
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
const TEST_MODEL_SPEC = "anthropic/claude-sonnet-4-5-20250929";
describe("PiSessionService", () => {
describe("spawnSession", () => {
@@ -25,7 +26,7 @@ describe("PiSessionService", () => {
it("starts a session at the resolved target, delivers the prompt, and logs the spawn", async () => {
const { fake, service, log } = spawnService({ allowed: true, cwd: "/workspace-feature" });
const result = await service.spawnSession({ spawningCwd: "/workspace", prompt: "continue the plan", cwd: "/workspace-feature" });
const result = await service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "spawner-1", prompt: "continue the plan", cwd: "/workspace-feature" });
expect(result).toEqual({ sessionId: "spawned-1", cwd: "/workspace-feature" });
expect(fake.calls.prompt).toEqual([{ text: "continue the plan", options: undefined }]);
@@ -53,17 +54,80 @@ describe("PiSessionService", () => {
heartbeatIntervalMs: 60_000,
});
await service.spawnSession({ spawningCwd: "/workspace", prompt: "continue", cwd: "/workspace-feature", model });
await service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "spawner-1", prompt: "continue", cwd: "/workspace-feature", model });
expect(initialModel).toBe(model);
expect(delegationToolsEnabled).toBe(true);
await service.dispose();
});
it("passes the dispatching session's thinking level to the spawned session's runtime", async () => {
const fake = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl" });
let initialThinkingLevel: unknown;
const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => {
await Promise.resolve();
initialThinkingLevel = options.initialThinkingLevel;
return fake.runtime;
};
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime,
sessionManager: sessionGateway([]),
spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) },
heartbeatIntervalMs: 60_000,
});
await service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "spawner-1", prompt: "continue", cwd: "/workspace-feature", thinkingLevel: "high" });
expect(initialThinkingLevel).toBe("high");
await service.dispose();
});
it("leaves the spawned session's thinking level to pi defaults when the dispatcher has none", async () => {
const fake = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl" });
let initialThinkingLevel: unknown = "unset";
const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => {
await Promise.resolve();
initialThinkingLevel = options.initialThinkingLevel;
return fake.runtime;
};
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime,
sessionManager: sessionGateway([]),
spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) },
heartbeatIntervalMs: 60_000,
});
await service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "spawner-1", prompt: "continue", cwd: "/workspace-feature" });
expect(initialThinkingLevel).toBeUndefined();
await service.dispose();
});
it("names the spawned session's model in the result", async () => {
const spawned = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl", model: testModel() });
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(spawned.runtime),
sessionManager: sessionGateway([]),
spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) },
heartbeatIntervalMs: 60_000,
});
const result = await service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "spawner-1", prompt: "continue", cwd: "/workspace-feature" });
expect(result).toEqual({ sessionId: "spawned-1", cwd: "/workspace-feature", model: TEST_MODEL_SPEC });
await service.dispose();
});
it("rejects an out-of-project target without starting a session", async () => {
const { fake, service } = spawnService({ allowed: false, reason: "out-of-project", allowedCwds: ["/workspace"] });
await expect(service.spawnSession({ spawningCwd: "/workspace", prompt: "go", cwd: "/elsewhere" }))
await expect(service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "spawner-1", prompt: "go", cwd: "/elsewhere" }))
.rejects.toThrow("cwd must be a workspace of this project. Allowed: /workspace");
expect(fake.calls.prompt).toEqual([]);
expect(service.activeCount()).toBe(0);
@@ -73,7 +137,7 @@ describe("PiSessionService", () => {
it("rejects when the spawning session is not in a registered project", async () => {
const { service } = spawnService({ allowed: false, reason: "not-registered" });
await expect(service.spawnSession({ spawningCwd: "/workspace", prompt: "go", cwd: undefined }))
await expect(service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "spawner-1", prompt: "go", cwd: undefined }))
.rejects.toThrow("Spawning session is not in a registered project");
await service.dispose();
});
@@ -88,9 +152,120 @@ describe("PiSessionService", () => {
heartbeatIntervalMs: 60_000,
});
await expect(service.spawnSession({ spawningCwd: "/workspace", prompt: "go", cwd: undefined }))
await expect(service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "spawner-1", prompt: "go", cwd: undefined }))
.rejects.toThrow("Spawning sessions is disabled");
await service.dispose();
});
describe("model spec resolution", () => {
/**
* Harness: the spawner comes online via `service.start`, then the spawn
* creates the next queued runtime. `initialModels` records every
* creation-time model so tests can see exactly what the spawned session
* was started with.
*/
function specService(spawnerPatch: Parameters<typeof fakeRuntime>[1] = {}) {
const spawner = fakeRuntime("spawner-1", { sessionFile: "/tmp/spawner-1.jsonl", ...spawnerPatch });
const spawned = fakeRuntime("spawned-2", { sessionFile: "/tmp/spawned-2.jsonl", model: testModel() });
const initialModels: PiAgentSession["model"][] = [];
const runtimes = [spawner.runtime, spawned.runtime];
let index = 0;
const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => {
await Promise.resolve();
initialModels.push(options.initialModel);
const runtime = runtimes[index] ?? spawned.runtime;
index += 1;
return runtime;
};
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime,
sessionManager: sessionGateway([]),
spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) },
heartbeatIntervalMs: 60_000,
});
return { service, spawner, spawned, initialModels };
}
it("resolves the spec against the spawning session's scoped models and names it in the result", async () => {
const scoped = testModel();
const { service, initialModels } = specService({ scopedModels: [{ model: scoped }] });
await service.start("/workspace");
const result = await service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "spawner-1", prompt: "go", cwd: "/workspace-feature", modelSpec: TEST_MODEL_SPEC });
expect(initialModels).toHaveLength(2);
expect(initialModels[0]).toBeUndefined();
expect(initialModels[1]).toBe(scoped);
expect(result).toEqual({ sessionId: "spawned-2", cwd: "/workspace-feature", model: TEST_MODEL_SPEC });
await service.dispose();
});
it("falls back to a direct runtime lookup when the spec is not among the available candidates", async () => {
// The shared test runtime has no configured auth, so its available
// snapshot is empty; only the getModel fallback can resolve the spec.
const { service, initialModels } = specService();
await service.start("/workspace");
const result = await service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "spawner-1", prompt: "go", cwd: "/workspace-feature", modelSpec: TEST_MODEL_SPEC });
expect(initialModels[1]).toMatchObject({ provider: "anthropic", id: "claude-sonnet-4-5-20250929" });
expect(result).toEqual({ sessionId: "spawned-2", cwd: "/workspace-feature", model: TEST_MODEL_SPEC });
await service.dispose();
});
it.each(["no-slash", "anthropic/", "/id"])("rejects the malformed spec %s without starting a session", async (modelSpec) => {
const { service, spawned, initialModels } = specService({ scopedModels: [{ model: testModel() }] });
await service.start("/workspace");
await expect(service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "spawner-1", prompt: "go", cwd: "/workspace-feature", modelSpec }))
.rejects.toThrow(`Unknown model "${modelSpec}". Pass an exact "provider/model-id".`);
expect(initialModels).toEqual([undefined]);
expect(spawned.calls.prompt).toEqual([]);
expect(service.activeCount()).toBe(1);
await service.dispose();
});
it("rejects an unknown spec without starting a session", async () => {
const { service, spawned, initialModels } = specService({ scopedModels: [{ model: testModel() }] });
await service.start("/workspace");
await expect(service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "spawner-1", prompt: "go", cwd: "/workspace-feature", modelSpec: "anthropic/does-not-exist" }))
.rejects.toThrow('Unknown model "anthropic/does-not-exist". Pass an exact "provider/model-id".');
expect(initialModels).toEqual([undefined]);
expect(spawned.calls.prompt).toEqual([]);
expect(service.activeCount()).toBe(1);
await service.dispose();
});
it("rejects an unknown spec even when the spawning session has no available models", async () => {
const { service } = specService();
await service.start("/workspace");
await expect(service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "spawner-1", prompt: "go", cwd: "/workspace-feature", modelSpec: "ghost/model" }))
.rejects.toThrow('Unknown model "ghost/model". Pass an exact "provider/model-id".');
await service.dispose();
});
it("does not look up the spawning session when no model spec is given", async () => {
const spawned = fakeRuntime("spawned-2", { sessionFile: "/tmp/spawned-2.jsonl", model: testModel() });
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(spawned.runtime),
sessionManager: sessionGateway([]),
spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) },
heartbeatIntervalMs: 60_000,
});
// "ghost" is not a resolvable session, and the default path must not care.
const result = await service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "ghost", prompt: "go", cwd: "/workspace-feature" });
expect(result).toEqual({ sessionId: "spawned-2", cwd: "/workspace-feature", model: TEST_MODEL_SPEC });
expect(spawned.calls.prompt).toEqual([{ text: "go", options: undefined }]);
await service.dispose();
});
});
});
});
@@ -64,17 +64,19 @@ describe("PiSessionService", () => {
await service.dispose();
});
it("uses the parent model and disables delegation before creating the tracked child runtime", async () => {
it("uses the parent model and thinking level and disables delegation before creating the tracked child runtime", async () => {
const parent = fakeRuntime("parent-1", { sessionFile: "/tmp/parent-1.jsonl" });
const child = fakeRuntime("child-1", { sessionFile: "/tmp/child-1.jsonl", sessionManager: fakeSessionManager("/workspace-feature") });
const model = testModel();
const initialModels: PiAgentSession["model"][] = [];
const initialThinkingLevels: unknown[] = [];
const delegationCapabilities: boolean[] = [];
const runtimes = [parent.runtime, child.runtime];
let index = 0;
const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => {
await Promise.resolve();
initialModels.push(options.initialModel);
initialThinkingLevels.push(options.initialThinkingLevel);
delegationCapabilities.push(options.delegationToolsEnabled);
const runtime = runtimes[index] ?? child.runtime;
index += 1;
@@ -91,13 +93,46 @@ describe("PiSessionService", () => {
});
await service.start("/workspace");
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "do the slice", cwd: "/workspace-feature", model });
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "do the slice", cwd: "/workspace-feature", model, thinkingLevel: "max" });
expect(initialModels).toEqual([undefined, model]);
expect(initialThinkingLevels).toEqual([undefined, "max"]);
expect(delegationCapabilities).toEqual([true, false]);
await service.dispose();
});
it("resolves a model spec against the parent's models and names it in the result", async () => {
const scoped = testModel();
const parent = fakeRuntime("parent-1", { sessionFile: "/tmp/parent-1.jsonl", scopedModels: [{ model: scoped }] });
const child = fakeRuntime("child-1", { sessionFile: "/tmp/child-1.jsonl", sessionManager: fakeSessionManager("/workspace-feature"), model: scoped });
const initialModels: PiAgentSession["model"][] = [];
const runtimes = [parent.runtime, child.runtime];
let index = 0;
const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => {
await Promise.resolve();
initialModels.push(options.initialModel);
const runtime = runtimes[index] ?? child.runtime;
index += 1;
return runtime;
};
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime,
sessionManager: sessionGateway([]),
archiveStore: emptyArchiveStore(),
spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) },
heartbeatIntervalMs: 60_000,
});
await service.start("/workspace");
const result = await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "do the slice", cwd: "/workspace-feature", modelSpec: "anthropic/claude-sonnet-4-5-20250929" });
expect(initialModels[1]).toBe(scoped);
expect(result).toEqual({ sessionId: "child-1", cwd: "/workspace-feature", model: "anthropic/claude-sonnet-4-5-20250929" });
await service.dispose();
});
it("persists tracked child links in the parent and child sessions", async () => {
const parentPersisted: { customType: string; data?: unknown }[] = [];
const childPersisted: { customType: string; data?: unknown }[] = [];
+141 -21
View File
@@ -120,6 +120,30 @@ function spawnTargetError(decision: Extract<SpawnTargetDecision, { allowed: fals
return new Error(`cwd must be a workspace of this project. Allowed: ${decision.allowedCwds.join(", ")}`);
}
function modelSpecOf(model: { provider: string; id: string }): string {
return `${model.provider}/${model.id}`;
}
/**
* Parse a strict `provider/model-id` spec: split on the first `/` (model ids
* may themselves contain `/`) and require both parts to be non-empty.
*/
function parseModelSpec(spec: string): { provider: string; modelId: string } | undefined {
const slash = spec.indexOf("/");
if (slash <= 0 || slash === spec.length - 1) return undefined;
return { provider: spec.slice(0, slash), modelId: spec.slice(slash + 1) };
}
/**
* Error for a spawn-tool model spec that matched nothing. States the facts
* the bad spec and the required format with deliberately no model list
* (a list would invite guesses). The agent loop turns the throw into an
* error tool result; how to recover is the agent's call.
*/
function unknownSpawnModelError(modelSpec: string): Error {
return new Error(`Unknown model "${modelSpec}". Pass an exact "provider/model-id".`);
}
function authLossWarningKey(sessionId: string, provider: string, modelId: string): string {
return `${sessionId}:${provider}/${modelId}`;
}
@@ -219,6 +243,11 @@ type SessionCreationProvenance = "tracked-subsession";
interface StartSessionOptions {
parentSession?: string;
initialModel?: AgentModel;
/**
* Thinking level for the brand new session; omit to resolve from settings
* and pi defaults. Pi clamps it to the initial model's capabilities.
*/
initialThinkingLevel?: ClientThinkingLevel;
/**
* Opaque label, echoed on this construction's startup progress so a browser
* row with no session id yet can recognise its own.
@@ -415,7 +444,7 @@ interface PendingSessionOpen {
promise: Promise<ActiveSession<PiSessionRuntime>>;
}
interface CreateSessionRuntimeOptions extends Pick<InternalStartSessionOptions, "initialModel" | "creationProvenance" | "startupToken"> {
interface CreateSessionRuntimeOptions extends Pick<InternalStartSessionOptions, "initialModel" | "initialThinkingLevel" | "creationProvenance" | "startupToken"> {
notificationGeneration?: SessionNotificationGeneration;
notifications?: "enabled" | "disabled";
/**
@@ -583,11 +612,13 @@ interface CreateAgentRuntimeOptions {
sessionManager: PiSessionManager;
delegationToolsEnabled: boolean;
initialModel?: AgentModel;
initialThinkingLevel?: ClientThinkingLevel;
}
type PiWebRuntimeFactoryOptions = Parameters<CreateAgentSessionRuntimeFactory>[0] & {
delegationToolsEnabled?: boolean;
initialModel?: AgentModel;
initialThinkingLevel?: ClientThinkingLevel;
};
type PiWebCreateAgentSessionRuntimeFactory = (
@@ -598,7 +629,7 @@ type CreateAgentRuntime = (createRuntime: PiWebCreateAgentSessionRuntimeFactory,
function defaultCreateAgentRuntime(createRuntime: PiWebCreateAgentSessionRuntimeFactory, options: CreateAgentRuntimeOptions): Promise<PiSessionRuntime> {
if (!(options.sessionManager instanceof SessionManager)) throw new Error("Default runtime creation requires an SDK SessionManager");
const runtimeFactory = createRuntimeWithOneShotSessionOptions(createRuntime, options.initialModel, options.delegationToolsEnabled);
const runtimeFactory = createRuntimeWithOneShotSessionOptions(createRuntime, options.initialModel, options.initialThinkingLevel, options.delegationToolsEnabled);
return createAgentSessionRuntime(runtimeFactory, {
cwd: options.cwd,
agentDir: options.agentDir,
@@ -609,20 +640,26 @@ function defaultCreateAgentRuntime(createRuntime: PiWebCreateAgentSessionRuntime
function createRuntimeWithOneShotSessionOptions(
createRuntime: PiWebCreateAgentSessionRuntimeFactory,
initialModel: AgentModel | undefined,
initialThinkingLevel: ClientThinkingLevel | undefined,
delegationToolsEnabled: boolean,
): CreateAgentSessionRuntimeFactory {
// These inputs belong only to the session being opened. A later runtime
// replacement resolves its own model and delegation capability.
// replacement resolves its own model and delegation capability, and restores
// the thinking level from the existing session file.
let pendingInitialModel = initialModel;
let pendingInitialThinkingLevel = initialThinkingLevel;
let pendingDelegationToolsEnabled: boolean | undefined = delegationToolsEnabled;
return async (options) => {
const model = pendingInitialModel;
const thinkingLevel = pendingInitialThinkingLevel;
const toolsEnabled = pendingDelegationToolsEnabled;
pendingInitialModel = undefined;
pendingInitialThinkingLevel = undefined;
pendingDelegationToolsEnabled = undefined;
return createRuntime({
...options,
...(model === undefined ? {} : { initialModel: model }),
...(thinkingLevel === undefined ? {} : { initialThinkingLevel: thinkingLevel }),
...(toolsEnabled === undefined ? {} : { delegationToolsEnabled: toolsEnabled }),
});
};
@@ -654,7 +691,7 @@ function createDefaultRuntimeFactory(
subsessions?: SubsessionToolDeps,
askUser?: AskUserToolDeps,
): PiWebCreateAgentSessionRuntimeFactory {
return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, delegationToolsEnabled }) => {
return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, initialThinkingLevel, delegationToolsEnabled }) => {
const services: AgentSessionServices = await createAgentSessionServices({ cwd, agentDir, modelRuntime });
const resolvedDelegationToolsEnabled = delegationToolsEnabled
?? await sessionAllowsDelegationTools(sessionManager, sessionManagers);
@@ -665,6 +702,7 @@ function createDefaultRuntimeFactory(
customTools,
...(sessionStartEvent === undefined ? {} : { sessionStartEvent }),
...(initialModel === undefined ? {} : { model: initialModel }),
...(initialThinkingLevel === undefined ? {} : { thinkingLevel: initialThinkingLevel }),
});
return { ...result, services, diagnostics: services.diagnostics };
};
@@ -1005,6 +1043,9 @@ export class PiSessionService implements SessionRouteService {
this.clearUnreadPublicationRetry();
clearInterval(this.heartbeat);
this.clearCompactionDrainTimers();
// Same startup-park hazard as closeActive(): settle `session_start` dialogs
// of sessions still binding extensions before awaiting their pending opens.
for (const sessionId of this.startupSessions.keys()) this.endSessionExtensionDialogs(sessionId);
const pendingOpens = this.pendingSessionOpenPromises();
if (pendingOpens.length > 0) await Promise.allSettled(pendingOpens);
const activeSessions = Array.from(new Set(this.active.values()));
@@ -1143,6 +1184,7 @@ export class PiSessionService implements SessionRouteService {
startupIntent: "create",
...(options.startupToken === undefined ? {} : { startupToken: options.startupToken }),
...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
...(options.initialThinkingLevel === undefined ? {} : { initialThinkingLevel: options.initialThinkingLevel }),
...(options.creationProvenance === undefined ? {} : { creationProvenance: options.creationProvenance }),
},
);
@@ -1175,13 +1217,26 @@ export class PiSessionService implements SessionRouteService {
if (this.spawnTargets === undefined) throw new Error("Spawning sessions is disabled");
const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd);
if (!decision.allowed) throw spawnTargetError(decision);
const created = await this.start(decision.cwd, input.model === undefined ? {} : { initialModel: input.model });
// A model spec overrides the inherited model. Only a spec triggers a
// spawning-session lookup; the default path must not depend on it.
const model = input.modelSpec === undefined
? input.model
: await this.resolveSpawnModel(input.spawningSessionId, input.modelSpec);
const created = await this.start(decision.cwd, {
...(model === undefined ? {} : { initialModel: model }),
...(input.thinkingLevel === undefined ? {} : { initialThinkingLevel: input.thinkingLevel }),
});
const modelUsed = this.active.get(created.id)?.runtime.session.model;
await this.prompt(created.id, input.prompt);
this.logger.info(
{ spawningCwd: input.spawningCwd, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length },
"spawn_session started a new session",
);
return { sessionId: created.id, cwd: decision.cwd };
return {
sessionId: created.id,
cwd: decision.cwd,
...(modelUsed === undefined ? {} : { model: modelSpecOf(modelUsed) }),
};
}
/**
@@ -1194,11 +1249,18 @@ export class PiSessionService implements SessionRouteService {
if (this.spawnTargets === undefined) throw new Error("Spawning sessions is disabled");
const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd);
if (!decision.allowed) throw spawnTargetError(decision);
// A model spec overrides the inherited model and is resolved against the
// parent's model runtime; only a spec triggers that lookup.
const model = input.modelSpec === undefined
? input.model
: await this.resolveSpawnModel(input.parentSessionId, input.modelSpec);
const created = await this.startSession(decision.cwd, {
...(input.parentSessionFile === undefined ? {} : { parentSession: input.parentSessionFile }),
...(input.model === undefined ? {} : { initialModel: input.model }),
...(model === undefined ? {} : { initialModel: model }),
...(input.thinkingLevel === undefined ? {} : { initialThinkingLevel: input.thinkingLevel }),
creationProvenance: "tracked-subsession",
});
const modelUsed = this.active.get(created.id)?.runtime.session.model;
const parentSessionFile = nonEmptyString(input.parentSessionFile);
const link: TrackedSubsessionLink = {
parentSessionId: input.parentSessionId,
@@ -1215,7 +1277,42 @@ export class PiSessionService implements SessionRouteService {
{ parentSessionId: input.parentSessionId, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length },
"spawn_subsession started a tracked child session",
);
return { sessionId: created.id, cwd: decision.cwd };
return {
sessionId: created.id,
cwd: decision.cwd,
...(modelUsed === undefined ? {} : { model: modelSpecOf(modelUsed) }),
};
}
/**
* The models a session may pick from: its scoped set when model-scoped,
* otherwise the runtime's available snapshot. Refreshes the runtime catalog
* first so callers see newly configured providers and models.
*/
private async sessionModelCandidates(session: PiAgentSession): Promise<readonly AgentModel[]> {
await session.modelRuntime.refresh();
return session.scopedModels.length > 0
? session.scopedModels.map((scoped) => scoped.model)
: session.modelRuntime.getAvailableSnapshot();
}
/**
* Resolve a strict `provider/model-id` spec from a spawn tool against the
* *spawning* session's model runtime, using the same candidates
* {@link setModel} offers plus a direct runtime lookup as fallback. Unknown
* or malformed specs throw; the agent loop turns that into an error tool
* result the spawning agent can retry from.
*/
private async resolveSpawnModel(spawningSessionId: string, modelSpec: string): Promise<AgentModel> {
const session = await this.getOrOpen(spawningSessionId);
const parsed = parseModelSpec(modelSpec);
const candidates = await this.sessionModelCandidates(session);
const model = parsed === undefined
? undefined
: candidates.find((candidate) => candidate.provider === parsed.provider && candidate.id === parsed.modelId)
?? session.modelRuntime.getModel(parsed.provider, parsed.modelId);
if (model === undefined) throw unknownSpawnModelError(modelSpec);
return model;
}
/**
@@ -1813,16 +1910,13 @@ export class PiSessionService implements SessionRouteService {
const streamingMessage = session.state.streamingMessage;
const partial = streamingMessage === undefined || streamingMessage === null
? null
: projectBrowserMessage(streamingMessage);
: annotateAssistantThinkingLevel(projectBrowserMessage(streamingMessage), session.thinkingLevel);
return { seq, partial };
}
async availableModels(ref: PiSessionLookup): Promise<ClientSessionModel[]> {
const session = await this.getOrOpen(ref);
await session.modelRuntime.refresh();
const models = session.scopedModels.length > 0
? session.scopedModels.map((scoped) => scoped.model)
: session.modelRuntime.getAvailableSnapshot();
const models = await this.sessionModelCandidates(session);
return models.map(modelToClientModel);
}
@@ -1830,11 +1924,8 @@ export class PiSessionService implements SessionRouteService {
await this.assertWritable(ref);
const session = await this.getOrOpen(ref);
this.assertTreeNavigationInactive(session, "change models");
await session.modelRuntime.refresh();
const candidates = await this.sessionModelCandidates(session);
this.assertTreeNavigationInactive(session, "change models");
const candidates = session.scopedModels.length > 0
? session.scopedModels.map((scoped) => scoped.model)
: session.modelRuntime.getAvailableSnapshot();
const model = candidates.find((candidate) => candidate.provider === provider && candidate.id === modelId)
?? session.modelRuntime.getModel(provider, modelId);
if (model === undefined) throw new Error(`Model not found: ${provider}/${modelId}`);
@@ -2592,6 +2683,10 @@ export class PiSessionService implements SessionRouteService {
}
private async closeActive(sessionId: string, notificationPolicy: NotificationClosePolicy = CLEAR_RUNTIME_NOTIFICATIONS): Promise<void> {
// A session whose open is parked on a `session_start` dialog holds its
// pending open until the dialog settles; settle it first so closing cannot
// block behind the dialog timeout (which `0` makes infinite).
if (this.startupSessions.has(sessionId)) this.endSessionExtensionDialogs(sessionId);
const pendingOpens = this.pendingSessionOpenPromises(sessionId);
if (pendingOpens.length > 0) await Promise.allSettled(pendingOpens);
const active = this.active.get(sessionId);
@@ -2788,6 +2883,7 @@ export class PiSessionService implements SessionRouteService {
sessionManager,
delegationToolsEnabled,
...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
...(options.initialThinkingLevel === undefined ? {} : { initialThinkingLevel: options.initialThinkingLevel }),
});
const active: ActiveSession<PiSessionRuntime> = { runtime, unsubscribe: noop };
let boundSession = runtime.session;
@@ -3124,10 +3220,13 @@ export class PiSessionService implements SessionRouteService {
}
}
active.unsubscribe = session.subscribe((event) => {
this.events.publish(session.sessionId, toClientEvent(event));
this.events.publish(session.sessionId, toClientEvent(event, session.thinkingLevel));
this.publishActivityForEvent(session, event);
const eventType = getString(event, "type");
if (eventType === "agent_end") this.abortRunScopedExtensionDialogs(session.sessionId);
// Pi itself emits agent_settled only after automatic retries, compaction,
// and queued continuations are exhausted. Preserve that native lifecycle
// event; never infer it from agent_end or mutable runtime flags.
if (eventType === "compaction_end") this.scheduleCompactionQueueDrain(session.sessionId);
if (eventType === "agent_start" || eventType === "agent_end") this.scheduleCompactionQueueDrain(session.sessionId);
this.publishStatus(session);
@@ -4023,11 +4122,30 @@ function stringValue(value: unknown): string {
return typeof value === "string" ? value : "";
}
/**
* Attach the thinking level in effect when an assistant message was generated,
* so chat bubbles can show it next to the model. Non-assistant messages pass
* through by reference; assistant messages are copied only when a level is set.
* "off" is the absence of thinking, not a level worth labeling on every bubble.
*/
function annotateAssistantThinkingLevel(message: unknown, thinkingLevel: string | undefined): unknown {
if (thinkingLevel === undefined || thinkingLevel === "" || thinkingLevel === "off") return message;
if (!isRecord(message) || message["role"] !== "assistant") return message;
return { ...message, thinkingLevel };
}
function historyMessages(session: PiAgentSession): unknown[] {
const messages: unknown[] = [];
// Pi records the initial level at session creation and every later change, so
// walking the branch yields the level in effect for each assistant message.
let thinkingLevel: string | undefined;
for (const entry of session.sessionManager.getBranch()) {
if (!isRecord(entry)) continue;
if (entry["type"] === "message") messages.push(entry["message"]);
if (entry["type"] === "message") messages.push(annotateAssistantThinkingLevel(entry["message"], thinkingLevel));
else if (entry["type"] === "thinking_level_change") {
const level = getString(entry, "thinkingLevel");
if (level !== undefined) thinkingLevel = level;
}
else if (entry["type"] === "custom_message" && entry["display"] === true) messages.push({ role: "custom", content: entry["content"], customType: entry["customType"], details: entry["details"] });
else if (entry["type"] === "compaction") messages.push({ role: "system", source: "compaction", content: `Compacted history:\n\n${stringValue(entry["summary"])}` });
else if (entry["type"] === "branch_summary") messages.push({ role: "system", source: "branch_summary", content: `Branch summary:\n\n${stringValue(entry["summary"])}` });
@@ -4071,7 +4189,7 @@ function finalAssistantText(messages: readonly unknown[]): string {
return "";
}
function toClientEvent(event: unknown): SessionUiEvent {
function toClientEvent(event: unknown, thinkingLevel?: string): SessionUiEvent {
const eventType = getString(event, "type");
const assistantMessageEvent = getProperty(event, "assistantMessageEvent");
if (eventType === "message_update" && getString(assistantMessageEvent, "type") === "text_delta") {
@@ -4094,9 +4212,11 @@ function toClientEvent(event: unknown): SessionUiEvent {
}
if (eventType === "agent_start") return { type: "agent.start" };
if (eventType === "agent_end") return { type: "agent.end" };
if (eventType === "agent_settled") return { type: "agent.settled" };
if (eventType === "message_end") {
const message = getProperty(event, "message");
return message === undefined ? { type: "message.end" } : { type: "message.end", message };
if (message === undefined) return { type: "message.end" };
return { type: "message.end", message: annotateAssistantThinkingLevel(message, thinkingLevel) };
}
return { type: "pi.event", eventType: eventType ?? "unknown" };
}
+43 -11
View File
@@ -2,20 +2,23 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import { describe, expect, it, vi } from "vitest";
import { createSpawnSessionToolDefinition } from "./spawnSessionTool.js";
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test stub with the minimal surface the tool reads.
const ctx = {} as ExtensionContext;
const dispatchModel = { provider: "anthropic", id: "claude-sonnet" };
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test stub with the minimal surface the tool reads.
const ctxWithModel = { model: dispatchModel } as ExtensionContext;
function ctxFor(sessionId: string, model?: unknown, thinkingLevel?: string): ExtensionContext {
const sessionManager = { getSessionId: () => sessionId };
// The spawn tool only reads sessionManager.getSessionId, model, and thinkingLevel.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test stub with the minimal surface the tool reads.
return { sessionManager, ...(model === undefined ? {} : { model }), ...(thinkingLevel === undefined ? {} : { thinkingLevel }) } as unknown as ExtensionContext;
}
describe("createSpawnSessionToolDefinition", () => {
it("passes the spawning cwd, explicit cwd, dispatching model, and prompt to spawn callback", async () => {
it("passes the spawning identity, explicit cwd, dispatching model, thinking level, and prompt to spawn callback", async () => {
const spawn = vi.fn(() => Promise.resolve({ sessionId: "new-1", cwd: "/repos/a-feature" }));
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn });
const result = await tool.execute("call-1", { prompt: "do the thing", cwd: "/repos/a-feature" }, undefined, undefined, ctxWithModel);
const result = await tool.execute("call-1", { prompt: "do the thing", cwd: "/repos/a-feature" }, undefined, undefined, ctxFor("spawner-1", dispatchModel, "high"));
expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", prompt: "do the thing", cwd: "/repos/a-feature", model: dispatchModel });
expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", spawningSessionId: "spawner-1", prompt: "do the thing", cwd: "/repos/a-feature", model: dispatchModel, thinkingLevel: "high" });
expect(result.details).toEqual({ sessionId: "new-1", cwd: "/repos/a-feature" });
expect(result.content[0]).toMatchObject({ type: "text", text: "Started independent session new-1 in /repos/a-feature." });
});
@@ -28,20 +31,49 @@ describe("createSpawnSessionToolDefinition", () => {
expect(tool.description).not.toMatch(/subsession|child|parent/i);
});
it("forwards omitted cwd as undefined and omits a missing dispatching model", async () => {
it("forwards omitted cwd as undefined and omits a missing dispatching model and thinking level", async () => {
const spawn = vi.fn(() => Promise.resolve({ sessionId: "new-2", cwd: "/repos/a" }));
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn });
await tool.execute("call-2", { prompt: "continue" }, undefined, undefined, ctx);
await tool.execute("call-2", { prompt: "continue" }, undefined, undefined, ctxFor("spawner-1"));
expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", prompt: "continue", cwd: undefined });
expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", spawningSessionId: "spawner-1", prompt: "continue", cwd: undefined });
});
it("forwards an explicit model as a model spec alongside the inherited model", async () => {
const spawn = vi.fn(() => Promise.resolve({ sessionId: "new-3", cwd: "/repos/a", model: "openai/gpt-5" }));
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn });
const result = await tool.execute("call-3", { prompt: "continue", model: "openai/gpt-5" }, undefined, undefined, ctxFor("spawner-1", dispatchModel));
expect(spawn).toHaveBeenCalledWith({
spawningCwd: "/repos/a",
spawningSessionId: "spawner-1",
prompt: "continue",
cwd: undefined,
model: dispatchModel,
modelSpec: "openai/gpt-5",
});
expect(result.details).toEqual({ sessionId: "new-3", cwd: "/repos/a", model: "openai/gpt-5" });
expect(result.content[0]).toMatchObject({ type: "text", text: "Started independent session new-3 in /repos/a using model openai/gpt-5." });
});
it("teaches the model parameter format and the #provider/model-id reference convention", () => {
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn: vi.fn() });
expect(tool.parameters).toMatchObject({
properties: {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- stringMatching yields `any` against the loosely typed tool schema.
model: { description: expect.stringMatching(/provider\/model-id.*#provider\/model-id.*Omit to inherit/s) },
},
});
});
it("propagates the spawn callback error so the agent loop reports it", async () => {
const spawn = vi.fn(() => Promise.reject(new Error("cwd must be a workspace of this project. Allowed: /repos/a")));
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn });
await expect(tool.execute("call-4", { prompt: "x", cwd: "/elsewhere" }, undefined, undefined, ctx))
await expect(tool.execute("call-4", { prompt: "x", cwd: "/elsewhere" }, undefined, undefined, ctxFor("spawner-1")))
.rejects.toThrow("cwd must be a workspace of this project. Allowed: /repos/a");
});
});
+17 -1
View File
@@ -4,16 +4,25 @@ import { defineTool, type ExtensionContext } from "@earendil-works/pi-coding-age
export interface SpawnSessionResult {
sessionId: string;
cwd: string;
/** Model the spawned session runs with, as `provider/id`; absent when unknown. */
model?: string;
}
export type SpawnSessionModel = NonNullable<ExtensionContext["model"]>;
export type SpawnSessionThinkingLevel = NonNullable<ExtensionContext["thinkingLevel"]>;
export interface SpawnSessionInvocation {
spawningCwd: string;
/** Id of the dispatching session; used to resolve {@link modelSpec} against its model runtime. */
spawningSessionId: string;
prompt: string;
cwd: string | undefined;
/** Current model from the dispatching session, used as the spawned session's default. */
model?: SpawnSessionModel;
/** Strict `provider/model-id` requested by the dispatcher; overrides {@link model} when set. */
modelSpec?: string;
/** Dispatching session's current thinking level, inherited by the spawned session (pi clamps it to the spawned model's capabilities). */
thinkingLevel?: SpawnSessionThinkingLevel;
}
export interface SpawnSessionToolDeps {
@@ -29,6 +38,9 @@ const SpawnSessionParams = Type.Object({
cwd: Type.Optional(Type.String({
description: "Working directory for the new session. Must be a workspace (worktree, or root) of the same project as this session. Defaults to this session's working directory.",
})),
model: Type.Optional(Type.String({
description: 'Model for the new session, as an exact "provider/model-id" such as "anthropic/claude-sonnet-4-5". When the user references a model as #provider/model-id in their request, forward it here. An unknown value is rejected. Omit to inherit this session\'s model.',
})),
});
/**
@@ -50,12 +62,16 @@ export function createSpawnSessionToolDefinition(spawningCwd: string, deps: Spaw
// valid workspace) rather than crash.
const result = await deps.spawn({
spawningCwd,
spawningSessionId: ctx.sessionManager.getSessionId(),
prompt: params.prompt,
cwd: params.cwd,
...(ctx.model === undefined ? {} : { model: ctx.model }),
...(params.model === undefined ? {} : { modelSpec: params.model }),
...(ctx.thinkingLevel === undefined ? {} : { thinkingLevel: ctx.thinkingLevel }),
});
const modelNote = result.model === undefined ? "" : ` using model ${result.model}`;
return {
content: [{ type: "text", text: `Started independent session ${result.sessionId} in ${result.cwd}.` }],
content: [{ type: "text", text: `Started independent session ${result.sessionId} in ${result.cwd}${modelNote}.` }],
details: result,
};
},
@@ -5,11 +5,11 @@ import { createSubsessionToolDefinitions, type SubsessionToolDeps } from "./spaw
const dispatchModel = { provider: "anthropic", id: "claude-sonnet" };
function ctxFor(sessionId: string, sessionFile: string | undefined, model?: unknown): ExtensionContext {
function ctxFor(sessionId: string, sessionFile: string | undefined, model?: unknown, thinkingLevel?: string): ExtensionContext {
const sessionManager = { getSessionId: () => sessionId, getSessionFile: () => sessionFile };
// The subsession tools only read sessionManager.getSessionId/getSessionFile and model.
// The subsession tools only read sessionManager.getSessionId/getSessionFile, model, and thinkingLevel.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test stub with the minimal surface the tools use.
return { sessionManager, ...(model === undefined ? {} : { model }) } as unknown as ExtensionContext;
return { sessionManager, ...(model === undefined ? {} : { model }), ...(thinkingLevel === undefined ? {} : { thinkingLevel }) } as unknown as ExtensionContext;
}
function tools(deps: Partial<SubsessionToolDeps>) {
@@ -48,7 +48,7 @@ describe("createSubsessionToolDefinitions", () => {
const spawn = vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/repos/a-feature" }));
const { spawn: spawnTool } = tools({ spawn });
const result = await spawnTool.execute("call-1", { prompt: "do it", cwd: "/repos/a-feature" }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl", dispatchModel));
const result = await spawnTool.execute("call-1", { prompt: "do it", cwd: "/repos/a-feature" }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl", dispatchModel, "max"));
expect(spawn).toHaveBeenCalledWith({
spawningCwd: "/repos/a",
@@ -57,6 +57,7 @@ describe("createSubsessionToolDefinitions", () => {
prompt: "do it",
cwd: "/repos/a-feature",
model: dispatchModel,
thinkingLevel: "max",
});
expect(result.details).toEqual({ sessionId: "child-1", cwd: "/repos/a-feature" });
expect(firstText(result.content)).toContain("Started tracked subsession child-1");
@@ -112,6 +113,36 @@ describe("createSubsessionToolDefinitions", () => {
});
});
it("spawn_subsession forwards an explicit model as a model spec and names the model used", async () => {
const spawn = vi.fn(() => Promise.resolve({ sessionId: "child-3", cwd: "/repos/a", model: "openai/gpt-5" }));
const { spawn: spawnTool } = tools({ spawn });
const result = await spawnTool.execute("call-model", { prompt: "do it", model: "openai/gpt-5" }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl", dispatchModel));
expect(spawn).toHaveBeenCalledWith({
spawningCwd: "/repos/a",
parentSessionId: "parent-1",
parentSessionFile: "/sessions/parent-1.jsonl",
prompt: "do it",
cwd: undefined,
model: dispatchModel,
modelSpec: "openai/gpt-5",
});
expect(result.details).toEqual({ sessionId: "child-3", cwd: "/repos/a", model: "openai/gpt-5" });
expect(firstText(result.content)).toBe("Started tracked subsession child-3 in /repos/a using model openai/gpt-5. Continue other work, then join with yield_to_subsessions; do not poll.");
});
it("spawn_subsession teaches the model parameter format and the #provider/model-id reference convention", () => {
const { spawn: spawnTool } = tools({});
expect(spawnTool.parameters).toMatchObject({
properties: {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- stringMatching yields `any` against the loosely typed tool schema.
model: { description: expect.stringMatching(/provider\/model-id.*#provider\/model-id.*Omit to inherit/s) },
},
});
});
it("list_subsessions reports the caller's subsessions and their status", async () => {
const list = vi.fn(() => Promise.resolve([
{ sessionId: "child-1", cwd: "/repos/a", status: "working" as const },
+14 -1
View File
@@ -8,9 +8,12 @@ export type SubsessionStatus = "working" | "idle" | "error" | "unknown";
export interface SpawnSubsessionResult {
sessionId: string;
cwd: string;
/** Model the child session runs with, as `provider/id`; absent when unknown. */
model?: string;
}
export type SpawnSubsessionModel = NonNullable<ExtensionContext["model"]>;
export type SpawnSubsessionThinkingLevel = NonNullable<ExtensionContext["thinkingLevel"]>;
export interface SpawnSubsessionInvocation {
/** cwd of the session that invoked the tool (used for project-scope checks). */
@@ -23,6 +26,10 @@ export interface SpawnSubsessionInvocation {
cwd: string | undefined;
/** Current model from the dispatching session, used as the spawned session's default. */
model?: SpawnSubsessionModel;
/** Strict `provider/model-id` requested by the parent; overrides {@link model} when set. */
modelSpec?: string;
/** Parent's current thinking level, inherited by the child session (pi clamps it to the child model's capabilities). */
thinkingLevel?: SpawnSubsessionThinkingLevel;
}
export interface SubsessionSummary {
@@ -72,6 +79,9 @@ const SpawnSubsessionParams = Type.Object({
cwd: Type.Optional(Type.String({
description: "Child workspace in the same project (worktree or root); defaults to the parent's directory.",
})),
model: Type.Optional(Type.String({
description: 'Model for the child session, as an exact "provider/model-id" such as "anthropic/claude-sonnet-4-5". When the user references a model as #provider/model-id in their request, forward it here. An unknown value is rejected. Omit to inherit this session\'s model.',
})),
});
const ListSubsessionsParams = Type.Object({});
@@ -197,9 +207,12 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
prompt: params.prompt,
cwd: params.cwd,
...(ctx.model === undefined ? {} : { model: ctx.model }),
...(params.model === undefined ? {} : { modelSpec: params.model }),
...(ctx.thinkingLevel === undefined ? {} : { thinkingLevel: ctx.thinkingLevel }),
});
const modelNote = result.model === undefined ? "" : ` using model ${result.model}`;
return {
content: [{ type: "text", text: `Started tracked subsession ${result.sessionId} in ${result.cwd}. Continue other work, then join with yield_to_subsessions; do not poll.` }],
content: [{ type: "text", text: `Started tracked subsession ${result.sessionId} in ${result.cwd}${modelNote}. Continue other work, then join with yield_to_subsessions; do not poll.` }],
details: result,
};
},
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
import type { OpenAPIV3 } from "openapi-types";
export const voiceApiOpenApi: OpenAPIV3.Document = {
openapi: "3.0.3",
info: {
title: "PI WEB Voice Pipeline API",
version: "1.0.0",
description: "Authenticated, server-side Azure Speech voice conversations for native and embedded clients. Audio is sent over the documented WebSocket protocol; Azure credentials never leave PI WEB.",
},
servers: [{ url: "/", description: "Current PI WEB HTTPS origin" }],
components: {
securitySchemes: {
bearerAuth: { type: "http", scheme: "bearer", bearerFormat: "pwv1 device token" },
},
schemas: {
Error: { type: "object", required: ["error"], properties: { error: { type: "string" } } },
VoiceTarget: { type: "object", required: ["id", "projectId", "label", "path"], properties: { id: { type: "string" }, projectId: { type: "string" }, label: { type: "string" }, path: { type: "string", description: "Registered workspace directory" } } },
CreateConversation: { type: "object", required: ["workspaceId"], properties: { workspaceId: { type: "string" }, model: { type: "string", example: "openai-codex/gpt-5.6-terra" }, thinking: { type: "string", example: "high" }, context: { type: "string", maxLength: 12000, description: "Additional instructions appended to the turn" } } },
Conversation: { type: "object", required: ["id", "sessionId", "workspaceId", "cwd", "status"], properties: { id: { type: "string" }, sessionId: { type: "string" }, workspaceId: { type: "string" }, cwd: { type: "string" }, status: { type: "string", enum: ["input-ready", "working", "closed"] }, model: { type: "string" }, thinking: { type: "string" } } },
},
},
security: [{ bearerAuth: [] }],
paths: {
"/api/v1/voice/targets": {
get: { summary: "List authorized voice targets", responses: { "200": { description: "Registered workspaces permitted by this device token", content: { "application/json": { schema: { type: "object", required: ["workspaces", "scopes"], properties: { workspaces: { type: "array", items: { $ref: "#/components/schemas/VoiceTarget" } }, scopes: { type: "object" } } } } } }, "401": { description: "Missing or invalid token", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } } },
},
"/api/v1/voice/conversations": {
get: { summary: "List prior sessions in an authorized workspace", parameters: [{ name: "workspaceId", in: "query", required: true, schema: { type: "string" } }], responses: { "200": { description: "Previous PI sessions rooted in the workspace" }, "400": { description: "Invalid or unauthorized workspace" } } },
post: { summary: "Create a voice conversation", requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/CreateConversation" } } } }, responses: { "201": { description: "Conversation created", content: { "application/json": { schema: { $ref: "#/components/schemas/Conversation" } } } }, "400": { description: "Invalid or unauthorized requested scope", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } } },
},
"/api/v1/voice/conversations/path": {
post: { summary: "Create a conversation in an existing directory beneath the device-safe root", requestBody: { required: true, content: { "application/json": { schema: { type: "object", required: ["path"], properties: { path: { type: "string", example: "/home/hope/workspaces/new-project" }, model: { type: "string" }, thinking: { type: "string" }, context: { type: "string" } } } } } }, responses: { "201": { description: "Conversation created" }, "400": { description: "Path is missing, outside the safe root, or not a directory" } } },
},
"/api/v1/voice/conversations/resume": {
post: { summary: "Resume a prior workspace session", requestBody: { required: true, content: { "application/json": { schema: { type: "object", required: ["workspaceId", "sessionId"], properties: { workspaceId: { type: "string" }, sessionId: { type: "string" } } } } } }, responses: { "201": { description: "Conversation handle attached to prior session", content: { "application/json": { schema: { $ref: "#/components/schemas/Conversation" } } } }, "400": { description: "Session is not in the authorized workspace" } } },
},
"/api/v1/voice/conversations/{id}/models": {
get: { summary: "List available models for a conversation", parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "Models available to the session and permitted by this device token" }, "404": { description: "Conversation not found or not owned by this token" } } },
},
"/api/v1/voice/conversations/{id}": {
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
get: { summary: "Read a conversation", responses: { "200": { description: "Conversation", content: { "application/json": { schema: { $ref: "#/components/schemas/Conversation" } } } }, "404": { description: "Not found or not owned by this token" } } },
delete: { summary: "Close a conversation", responses: { "204": { description: "Closed" }, "404": { description: "Not found or not owned by this token" } } },
},
"/api/v1/voice/stream": {
get: { summary: "Voice audio WebSocket", description: "Upgrade to WebSocket using the bearer token. Send `attach`, then binary signed 16-bit little-endian PCM at 16 kHz mono, followed by `input.end`. Server emits transcript JSON, `assistant.final`, and raw 24 kHz mono PCM binary frames. See docs/voice-api.md for the binary frame layout.", responses: { "101": { description: "WebSocket upgraded" }, "401": { description: "Invalid token" } } },
},
},
};
+25 -3
View File
@@ -77,6 +77,26 @@ export interface PiWebAgentConfig {
dir?: string;
}
export interface AzureSpeechSettings {
region: string;
voice: string;
/** Deliberately the only key-related field exposed to the browser. */
hasApiKey: boolean;
}
export interface AzureSpeechSettingsUpdate {
region: string;
voice: string;
/** Omit to retain the stored key; an empty string clears it. */
apiKey?: string;
}
export interface AzureSpeechToken {
token: string;
region: string;
voice: string;
}
export interface PiWebConfigValues {
host?: string;
port?: number;
@@ -656,9 +676,9 @@ export interface PendingExtensionDialog {
/**
* The complete result of a closed extension dialog. Unlike an ask outcome it
* stays small the dialog itself is not embedded, because a closed dialog
* renders only transiently for browsers that saw it open; reloads rehydrate
* open dialogs from {@link SessionStatus.pendingDialogs} alone.
* stays small the dialog itself is not embedded, because a settled card is a
* browser-local record that stays until the user dismisses it; reloads
* rehydrate open dialogs from {@link SessionStatus.pendingDialogs} alone.
*/
export interface ExtensionDialogOutcome {
dialogId: string;
@@ -1263,6 +1283,8 @@ type SessionUiEventBody =
| { type: "shell.end"; output?: string; exitCode?: number | null; cancelled?: boolean; truncated?: boolean; fullOutputPath?: string; isError?: boolean }
| { type: "agent.start" }
| { type: "agent.end" }
/** Pi-native completion: no retry, compaction retry, or queued continuation remains. */
| { type: "agent.settled" }
| { type: "message.end"; message?: unknown }
| { type: "status.update"; status: SessionStatus }
| { type: "activity.update"; activity: SessionActivity }