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]>
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]>
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.
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.
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.
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.
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.
A dialog opened from a session_start hook parks session construction
before the session ever becomes active, but every servable path gated on
readiness: the answer/cancel routes and status 404'd (or parked behind
the in-flight open), and the client only subscribed once its create
request resolved — so the dialog that gated readiness could never be
answered and always rode to the daemon timeout.
Daemon: hold a startupSessions registry for the duration of extension
binding and let status, answerDialog, and cancelDialog resolve active →
startup → getOrOpen fallback. getOrOpen itself is untouched, so prompts
and other mutations still cannot reach a half-constructed session.
status() no longer parks behind an in-flight open; it resolves from the
startup window (intended semantics change, lifecycle test updated).
Client: the pending-start row learns the real session id from the first
token-matched session.startup event, connects its otherwise idle session
socket to the constructing session, and recovers pre-subscription opens
with a merge-based status resync (the unordered HTTP snapshot only adopts
dialog ids the ordered socket channel never reported). The leg-4 dialog
card renders in the startup view with no component changes, and answers
go out under the real id. Readiness proceeds as before once the hook
settles.
A user abort while a tool_call dialog is parked deadlocked the dialog
until its timeout: pi's agent loop waits for the parked dialog handler
before emitting agent_end, and run-scoped dialogs were settled only on
agent_end. Settle them synchronously at abort-request time, before
awaiting the runtime abort, so a hung or failing abort cannot strand
the parked waiter. Keep the agent_end settlement as the run-crash
backstop; the store makes the double settlement a stale no-op.
Parse pendingDialogs on the session status and the dialog.opened /
dialog.closed socket events, track them per selected session in app
state (array, no supersede; closed dialogs kept with their outcome for
transient rendering), and add answerDialog/cancelDialog session API
methods on the dedicated dialogs routes, allowlisted for remote
machines.
ctx.ui.confirm()/select()/input() from extensions now open daemon-owned
pending dialog records, publish dialog.opened/dialog.closed, and park a
Promise that settles on the browser's answer or cancel, the extension's
own signal/timeout, the extensionDialogsTimeoutMs daemon default (5 min,
0 = forever; tuning knob, not a gate), agent_end for run-scoped dialogs,
or session-ended on close/replace/dispose. Answers resolve the parked
extension Promise directly via POST /sessions/:id/dialogs/answer|cancel
- never the prompt queue - with first-wins stale semantics across
browsers, and SessionStatus.pendingDialogs rehydrates reloading clients.
Domain layer for issue #106: a daemon-owned, per-session
PendingExtensionDialogStore (multiple open dialogs, no supersede,
kind-validated answers, stale-tolerant closes) plus the shared
PendingExtensionDialog/ExtensionDialogOutcome wire types,
SessionStatus.pendingDialogs, and dialog.opened/dialog.closed events.
Relay: issue-106-extension-dialogs leg 1
An ordinary chat message answers the session's open ask in the user's
own words, so keeping the form open would invite answers to questions
the conversation has already moved past. The form now closes as
cancelled, browsers clear the live card, and the model is told without
being woken so the notice rides into the turn the message itself
starts. Ignored duplicate queued messages skip the void on purpose:
they must not void an ask posted after the queued original.
Five tests failed only on the Windows runner. Their fixtures used bare
POSIX-absolute paths such as /srv/other-worktree and /old-project, which
win32 treats as absolute but drive-relative: resolve() maps them onto the
runner's current drive (D:\...). The code under test canonicalizes stored
cwds by contract, so assertions comparing against the raw fixture string
never matched on Windows:
- parentSessionLocator and crossWorkspace listings annotate
parentSessionCwd with canonicalizeStoredCwd(header.cwd);
- cleanup forgets unread via canonicalizeStoredCwd(record.cwd), which
missed the marker the test seeded with the raw path.
Resolve the fixture paths once at declaration, matching the existing
WORKSPACE_CWD = resolve("/workspace") convention, so fixtures model what
a real Windows Pi would record. Linux behavior is unchanged.
The migration moved ~/.pi-web/archived-sessions* into a custom
PI_WEB_DATA_DIR at session daemon startup. Remove it outright: data
directories are independent, and anyone who sets a custom data
directory can copy archived-sessions.json and archived-sessions/
manually while the daemon is stopped.
Without the migration, runSessionDaemonStartup's only remaining
contract was sequencing create/register/listen, so inline those steps
into sessiond.ts and drop the wrapper module and its tests.
A session spawned into another worktree recorded a parent that no
listing contained, so the row showed only "parent unavailable" and its
parent's row looked childless. Both facts were accurate and useless:
neither said where the related session actually was.
Report both directions from the session store instead. A missing
parent's cwd and id come from its own file header, so one 4 KB read per
distinct missing parent resolves it without listing other workspaces;
children are counted by listing sibling workspaces and matching the
parent path they already recorded, needing no header reads. Reads are
memoized per path because Pi writes headers once, and the cache is
released on dispose. Both directions are best-effort: an unreadable
header or an unlistable worktree leaves a session unannotated rather
than failing the listing.
In the browser, an orphan child keeps the same child marker as a nested
one, dimmed, so it no longer renders as a root; whereabouts are stated
once on the meta line ("parent in feature/foo", "2 children
elsewhere"), where a clamped title cannot hide them. A "Go to parent
session" action switches to the owning workspace and selects the
parent. Live session.created events keep child counts current instead
of leaving them stale until the next listing.
Session and workspace paths reach the browser from two producers: store
enumeration for a listing, and the live runtime for a broadcast. They
are now compared through one normalizing helper, so tree nesting and
child counts cannot silently miss a link when only a trailing separator
differs.
Extract the shared "workspaces of the project containing this cwd"
lookup out of ProjectScopedSpawnTargetResolver so spawn targeting and
child counting share one implementation, and register it regardless of
whether spawning is enabled: children can predate a config change, and
the tree should stay honest about them either way.
Rework the bundled Info plugin from a demo into an always-available
status view: running and installed versions, installation details,
release state, and per-service health rendered from host-provided
state, plus machine and workspace details. Replace the window.alert
demo action with a Copy PI WEB Diagnostics action that copies a
plain-text summary for bug reports.
Add state.selectedMachine to the stable plugin runtime state so
actions and other runtime callbacks can read the selected machine's
identity, and split the plugin into a copy-paste-friendly skeleton
(pi-web-plugin.ts) and replaceable internals (infoInternals.ts).
Unread is no longer a competing ActivityIndicatorKind or a separate
name-adjacent dot: every row renders a single indicator. An accent ring
wraps the still-pulsing work dot when a row is both busy and unread, a
filled accent dot shows while idle and unread, and activity kinds keep
their existing precedence (sending > session > terminal). Session rows
surface unread state even while busy, so the unread header count and
mobile Sessions badge now count busy unread sessions too.
Wire the derived UnreadPresence into dot indicators (no counts) across the
navigation panel: workspace, project, and machine rows (machine-switcher and
machine-list) now show a static accent dot whenever a session beneath them is
unread, including offline machines with stale-but-present state. Presence
flows PiWebApp -> AppNavigationPanel -> leaf id sets, mirroring the
unreadSessionIds chain, and is covered by happy-dom component tests per list
plus panel- and app-level wiring tests. Adds changesets for the mark-as-read
actions and the bubble-up indicators.
Live data refreshes (messageCount churn from session status publication,
workspace topology refreshes) replace the sessions/workspaces arrays and
the selected object with same-id copies, and each replacement re-scrolled
the selected row into view.
Replace the broad updated() scroll triggers with positive reveal triggers:
a different row becoming selected (first render included), the archived
reveal path, a restore moving the selected row from archived back to
current (same id, archived flag cleared), and section expansion. The
sessions/workspaces array triggers are removed, not guarded.
Add pure unreadPresence helpers mapping unread summary cwds to machine,
project, and workspace presence (machine = any summary, workspace = exact
path match, project = known workspace ownership), plus a PiWebApp seam
that keeps the derived UnreadPresence state current on any machine's
projection change and on app-state transitions. No UI consumption yet.
Add a "Mark as read" item to the session row menu (shown only for
unread, non-archived, non-transient sessions) and a bulk "Mark read"
action to the current-selection toolbar (enabled when any selected
session is unread). Both flow through AppNavigationPanel to PiWebApp,
which acknowledges via SessionUnreadController with the exact observed
completion order.
Relay unread-ux leg 1: audit verified every archive path (single, bulk,
tree, cleanup, restore, delete, rebind) clears unread state in the right
order with no resurrection vector. Add regression tests for the uncovered
paths: bulk archive, archive with descendants, cleanup, bulk delete, and
archiving an active session with a pending activity latch.
Parse the daemon-owned `pendingAsk` in `parseSessionStatus` and validate
`ask.opened` / `ask.closed` frames rather than accepting them on their type,
since they drive a form the user answers on the model's behalf.
Add `submitAsk` / `cancelAsk` through `request()` + `sessionPath()`, both
returning the recomputed session status so closing an ask needs no follow-up
status request, and derive `pendingAsk` in `SessionController` from status plus
live events with `sessions.askUser` capability gating.
New `askDrafts.ts` keeps what the user has typed in browser-local storage under
`pi-web:ask-draft:<sessionId>:<askId>` and owns the pure answer-state helpers,
so the daemon owns "there is an open ask" and the browser owns "what I have
typed so far".
One optional line in sessiond.ts passed the model catalog refresher into
PiSessionService, and it was the only thing making "· provider model lists are
refreshing" reachable in the product. Deleting it left typecheck, lint, Knip,
and all 1860 tests green while the note silently disappeared — confirmed by
actually deleting it and running each check. sessiond.ts starts a daemon as an
import side effect, so nothing in it could be tested.
Extract the dependency assembly into a pure sessionServiceDependencies()
function, following the sessionDaemonStartup.ts precedent, and assert what a
user is actually told: a service built by the real assembly, with a refresh in
flight, reports the note on its startup phases. A companion test pins the note
to the refresher's answer so the first cannot pass on unconditional wording.
Only the object literal moved. Everything createRuntime() constructs before it
stays in place and in order — the provider freeze must still precede any real
session, for the reason its comment gives — and the extracted function performs
no side effect, so construction order, side effects, and routes are unchanged.
The wiring is now guarded at both hops. Dropping the field from the assembly
fails the new test; dropping it from sessiond.ts fails typecheck, because the
assembly's input type requires every collaborator the daemon constructs.
PiSessionServiceDependencies.catalogRefreshStatus stays optional, so the 98
existing service constructions in the suite are untouched, and the refresher's
getter stays read-only: the test injects a fake in-flight status rather than
touching its cadence, timeout, or coalescing.
subsessionsEnabled's spawn-capability conjunction moved into the assembly with
the literal it lived in. The semantics are identical, and it is now covered by a
test instead of being another untested decision in an untestable file.
Startup progress rides the per-session activity channel with an "active"
phase, because a startup phase really is in progress. But isSessionActive()
treated any active activity as work, so a session that was merely *opening*
enabled "Stop Active Work", disabled "Reload from disk" with the misleading
"Stop current session activity before reloading" tooltip, showed the row's
active-work indicator, and — for any caller that hands a startup activity to
WorkspaceActivityService — reported the whole workspace as busy. Selecting an
archived, read-only session reported active work while it opened.
Starting is not working. publishStartupProgress now marks its reports with a
new optional SessionActivity.startup field, and isSessionActive() does not
count a marked activity. Every affected consumer — the session list, the core
actions, the app's activity-transition handling, and the server's workspace
aggregation — reads that one helper, so the correction lands in all of them at
once.
The marker is a new field rather than a new phase on purpose: six readers test
phase === "active" directly, including the pending row's "creating · " prefix,
the chat dock's active styling, and the daemon's own heartbeat re-publication.
It can only ever remove the activity-phase reason for being active, so
streaming, bash, compaction, and queued prompts still report as active through
the status even while a startup report is the latest activity. The chat dock
still shows the startup text; this changes what counts as work, not what is
shown.
The browser's own pending-create row keeps its previous appearance: it borrows
only the daemon's phase text and drops the marker, since that row stands for a
create the user is waiting on rather than a session the daemon is opening.
Integrate the pending-ask store into the session service so an open ask is
visible, observable, and closable.
- `statusFromSession` projects `pendingAsk`, so a browser rehydrates an open
ask from `GET /sessions/:sessionId/status` after reload or a web/API restart.
- `openAsk` publishes `ask.opened`, and publishes `ask.closed` first when the
new ask supersedes an unanswered one.
- `submitAsk` / `cancelAsk` close the ask and hand the outcome to the model as
a `pi-web.ask.answers` follow-up custom message (`triggerTurn`,
`deliverAs: "followUp"`), the same delivery subsession notices use. A stale
ask id is reported, not thrown: losing the race against a supersede or
another browser is ordinary. Cancel still reports every question as
unanswered so the model is not left waiting for a promised message.
- The open ask is forgotten when its runtime closes; nothing is left to
receive the answers.
- `POST /sessions/:sessionId/ask/{submit,cancel}` behind the existing
`/api/sessions/*` daemon proxy, allowlisted for machine federation.
The browser-resume path and the plugin-facing app refresh call
refreshSelectedProjectTopology independently, so two requests for the same
machine and project could be in flight at once. The stale guards check machine
and project but not ordering, so a slower earlier response landing last
overwrote a newer list, making a just-created worktree disappear again.
Route the refresh through TrailingRefreshCoordinator, the primitive already
used for browser resume, session refresh, and activity, keyed by machine and
project. A second caller no longer opens its own request while one is in
flight; it gets a single trailing pass, so the last applied response is the
newest one.
Nothing consumed GitWorktreeInfo.locked: a locked worktree is a real checkout
and stays a usable workspace, so no caller needs to distinguish it. Unknown
porcelain keys are already ignored, so parsing it was speculative.
The parser test still feeds a real `locked` line and asserts it is ignored, and
the service test still pins that a present, non-prunable worktree is kept, so
the policy stays covered without the field.
A background topology refresh replaced the workspace list but left
selectedWorkspace pointing at the old object, so a branch switched inside a
worktree outside PI WEB showed the new name in the list while the collapsed
Workspaces header and the mobile context bar kept the old one until reselect.
Re-point selectedWorkspace at its refreshed entry, keyed by id (derived from
the path), so which workspace is selected never changes and the session and
terminal teardown in handleWorkspaceChange still does not fire. Skip the patch
entirely when metadata is unchanged, so an ordinary resume does not churn
object identity into state on every focus.
Call WorkspaceController.refreshSelectedProjectTopology() from the existing
browser-resume refresh and the plugin-facing refreshAppData path, so worktrees
created or removed outside PI WEB become visible with no user action. No new
timer, watcher, process, or push channel; the resume path is already debounced
per animation frame and collapses concurrent requests.
Document the resume-scoped detection and the hiding of gone checkouts in the
FAQ, and add the changeset for the user-visible behavior.