Commit graph

7567 commits

Author SHA1 Message Date
Petr Houška
850ea2eae2 docs(coding-agent): changelog for MCP OAuth callback close hang
closes #11311
2026-11-05 23:57:00 +01:00
robopipi[bot]
63ea042ce5
fix(mcp): close remaining connections when the OAuth callback server closes (#11855)
After a successful MCP OAuth sign-in, `/mcp login` could stay on
"Connecting…" for minutes. `OAuthCallbackServer.close()` awaited
`http.Server.close()`, which resolves only after every socket has
closed. Browsers may keep an unused connection open to the callback port
(a preconnect, or a spare keep-alive socket). Node's idle-connection
cleanup doesn't close that socket, and `close()` also stops Node's
request timeout checks. So the wait lasted until the browser dropped the
socket. `signInMcpServer` awaits this cleanup before reconnecting, and
cancellation doesn't apply to it, so Ctrl+C did nothing.

`close()` now calls `server.closeAllConnections()` after starting the
shutdown. By the time the server closes, the callback response has
already been sent, so dropping the remaining connections is safe.

**Testing**
- Added a test to `packages/mcp/test/oauth.test.ts`: it opens a spare
TCP connection to the callback port, completes a callback, and expects
`close()` to resolve within 2 s. It fails without the change and passes
with it.
- `npm run check` passes. The MCP OAuth tests and the coding-agent MCP
tests pass.
- Script reproduction: with a spare connection open, `close()` did not
resolve within 400 s before the change, and resolves in about 1 ms after
it.

Fixes #11311

Requested by @petrroll

Co-authored-by: triagent <triagent@localhost>
2026-11-05 23:51:53 +01:00
Petr Houška
80d4d3c869
fix(coding-agent): remove all extension overlays on /reload and session switch (#11853)
## Problem

`InteractiveMode.resetExtensionUI()` runs on `/reload` and before a
session is invalidated (session switch, new session, fork). For overlays
it called `this.ui.hideOverlay()` once, which pops only the topmost
entry of the TUI overlay stack.

Trace: an extension opens two overlays with `ctx.ui.custom({ overlay:
true })`, then the user runs `/reload`. Reset removes the upper overlay;
the lower one stays on screen, gets focus back and keeps receiving
input, although the extension instance that created it is gone. A hidden
overlay (`handle.setHidden(true)`) also stays in the stack and can be
shown again by stale code.

Blindly clearing the whole stack is not correct either: pi opens its own
overlays (the header logo easter egg in `components/easter-egg-3d.ts`).
With the old code, a reset while the easter egg was topmost removed the
easter egg and left the extension overlay.

## Fix

`showExtensionCustom` records each overlay it opens in
`extensionOverlays` (handle -> removal function). The same function
serves `done()` and `resetExtensionUI()`: it hides the overlay, clears
its blocked program status (#11851) and disposes its component. Deleting
the map entry first makes a second call a no-op, so a late `done()`
after reset does not dispose twice. Reset calls it for every recorded
overlay, newest first, including hidden ones. Overlays not opened by
extensions are left alone. This replaces the separate
`blockingOverlaySources` set and reset loop added in #11851.

Pending `ctx.ui.custom()` promises of removed overlays stay unsettled,
matching how reset already treats inline dialogs.

## Testing

- New regression test in `test/interactive-mode-status.test.ts`: a
pi-internal overlay plus three extension overlays (one hidden), then
reset. Checks that the extension overlays are gone and disposed, the
internal overlay remains and receives input. Fails without the fix.
- `test/interactive-mode-status.test.ts`,
`test/interactive-mode-extension-dialogs.test.ts`,
`test/interactive-mode-paste.test.ts`,
`test/suite/regressions/5943-session-start-notify.test.ts` pass.
- `npm run check` clean.
2026-11-05 23:30:54 +01:00
Petr Houška
7f47a2ed09
feat(coding-agent): let ctx.ui.custom() overlays report blocked program status (#11851)
Closes #11816.

## Problem

`ctx.ui.custom()` accepted `blocked` only for inline components.
Overlays ignored it, so an extension question shown as an overlay left
pi reporting `working` over OSC 7501. Example: pi-ask-user in its
default overlay mode inside Herdr App never shows the session as blocked
and never notifies.

## Change

- Each overlay opened with `blocked` registers its own program status
source (`extension-overlay:N`) after `showOverlay()` and clears it when
the overlay calls `done()`.
- The status stays while the overlay is hidden with `setHidden()` or
covered by another overlay: the question is still unanswered.
- Separate sources per overlay, so closing one keeps other open
questions, dialogs and login prompts blocked. With several blocking
prompts, the most recently shown one supplies `kind` and `message`.
- Overlays without `blocked` (loaders etc.) register nothing and cannot
clear another overlay's status.
- `/reload` and session switches drop overlays without calling `done()`;
`resetExtensionUI()` now clears their blocked status so pi does not stay
blocked.
- Inline components are unchanged: they report the dialog shown in the
editor slot, so a non-blocking component shown in its place clears the
status until it closes.
- Docs (`docs/tui.md`, `custom()` JSDoc) and changelog updated.

## Testing

- `npm run check`
- `test/interactive-mode-extension-dialogs.test.ts`: new regression test
(two blocking overlays, one non-blocking overlay, hide, close in order)
and one for clearing the status on reset.

pi-ask-user still needs to pass `blocked` for its overlay prompts.
2026-11-05 23:23:58 +01:00
Armin Ronacher
36142c1c7e fix(ai,coding-agent): remove "(legacy)" marker from OpenAI Codex provider name 2026-11-05 22:51:36 +01:00
Azmeen Afandi
38e8d3b3fc
docs: drop trailing slash from pi RFCs link (#11838)
Minor typo but breaks the link to the actual pi RFCs on rfc.earendil.com

Co-authored-by: azmeen <azmeen@users.noreply.github.com>
2026-11-05 22:47:48 +01:00
David Brailovsky
d9af3d73af feat: p2 TUI through Radius (npm run tui -- --radius)
The launcher signs in to Radius itself and reaches its server as a Radius
client; the brain answers client channels with loopback requests as the
relay owner. Temporary: one Radius connection per request, bundle check
every 10 s; docs/p2-notes.md describes the single-connection follow-up.
2026-11-05 20:26:49 +01:00
Mario Zechner
e3c8c3b486 fix(p2): the composer renders its draft at once again
Since the one composer (ccff9876e), the screen published the composer's props, the draft included, into the dock a
layout effect after each render. The controlled textarea rendered the previous text first after every keystroke: the
caret jumped to the end when typing inside the draft, and a phone keyboard's composition broke, so the first tap on
Send only closed the keyboard. The composer reads the draft from the app's composer state itself.
2026-11-05 20:03:08 +01:00
Mario Zechner
52e9c17b00 fix(p2): one tap on Send sends on phones
Pressing Send blurred the composer's textarea, so a phone's keyboard closed, the page grew back and the button moved
out from under the finger before the click: the first tap only closed the keyboard. Send and Stop now keep the focus
in the textarea on pointerdown; the click still fires and the keyboard stays open.
2026-11-05 19:52:12 +01:00
Mario Zechner
20d86c3657 feat(p2): Chord v2 cleanup pass
p2 rewritten by the Chord v2 authoring rules (decision "App authoring rules confirmed
by the p2 audit"), in four parts merged together:
- core: session lifecycle (relocate, clone, fork, archive) and search rebuild are
  persisted jobs on a new server store; Sessions v6 with client-chosen ids and visible
  operations; Retry answers with the worker's real outcome; workers push branch,
  subagent and status state to the server; roots take the method's ctx.
- services: models, envs, ports, login, mcp, logs, sharing and voice follow external
  state (self.follow, followNative) instead of copy loops; methods answer after their
  state commits; presence from host.references.
- chat: chat mode, artifacts and prompt resources follow Durable and native state;
  shell commands are Chord jobs (shellId is the requestId); status pushed before
  send and stop answer; race-free draft claim.
- ui: optimistic updates on web (useOptimistic) and terminal (TuiScope.optimistic,
  extended in Chord with a reducer and a pending flag), pending states for long work,
  visible rollback, load waterfalls removed; merged with the one composer and
  immediate first send (ccff9876e) and the folder + button (5a31a6d5f).
docs/optimistic-ui.md describes the pattern.
2026-11-05 18:22:13 +00:00
Mario Zechner
1137075c78 fix(p2): touch selection, Copy and Paste in the web terminal
Every touch put the transparent input field under the finger, so a long press on
output selected in the empty field (Paste only) and never a word of the output;
the field's focus also opened the keyboard, whose resize redrew all rows. Rows
were replaced whenever output changed them, which dropped any selection.

Now a long press on a character of the output is Chrome's own (word, handles,
Copy); only a blank cell gets the field for Paste. Touch contextmenus are never
cancelled. While output is selected rows are not redrawn (15 s after the last
change, or until the selection ends or the user types, pastes or scrolls). Copy
joins soft-wrapped rows (new Frame row flag from libghostty) and drops the
blanks that fill rows.
2026-11-05 19:11:58 +01:00
Mario Zechner
a5d209afd0 feat(chord): reload restarts every affected object; old work finishes on old code
The pure swap is gone: a reload restarts every live object of a changed plugin
(documents and tables kept). Calls running at the commit and every call they cause
(the ctx call chain; an old flag on call from trusted links) run on the open old
attempt, which seals once its work drained and its successor's recorded requests are
ready, bounded by reloadDrainTimeout (5 s); then the successor's factory runs. New work
waits at the gate; same-host consumers see the draining object live; a reload cycle
fails at once with unavailable/reload_cycle. Example 64, design section 21 rewritten.
2026-11-05 18:07:11 +00:00
Armin Ronacher
5a31a6d5f4 feat(p2): add a + button to sidebar folders to start a new session there 2026-11-05 18:21:02 +01:00
Cristina Poncela Cubeiro
55a5c69145 fix(schema): num no longer takes nan and infinity 2026-11-05 14:20:32 +01:00
Maximilian Blazek
ccff9876e7 feat(p2): a new session's first message sends at once; one composer for every screen
- New session: Send clears the composer and shows the message at once, online or not
  (plugins/chat/session-start.ts); the session is created when connected and its
  messages move to the main conversation's outbox. Refused creations go back to the
  composer; connection failures are retried.
- One composer: Main keeps the chat frame (.chat and its dock) while screens change;
  screens publish what the dock shows (useChatScreen) and portal their transcript into
  it. The composer keeps its focus and the phone's keyboard when the session opens.
- The composer stays in place: the new-session screen shows the session's empty
  context bar, and a message sent to an idle conversation stays a message until its
  entry shows instead of flashing as a queued row.
- Slots start with the registry's known table (knownRegistry), so a screen inside
  slot wrappers mounts once instead of three times.
- A message sent before a conversation has loaded shows alone until it loads.
2026-11-05 12:52:52 +00:00
Cristina Poncela Cubeiro
136a7b2d7d
chore(licenses): rename dir (#11834)
Radius's control-plane client now bundles pi-schema, and Vite's license
step reads the first entry whose name starts with "license" as the
package's license file. Here that entry is pi-schema's LICENSES/ folder,
so Vite tries to read a directory as a file and the build fails, which
renaming the folder fixes without any Radius-side workaround.
2026-11-05 12:42:32 +01:00
Vegard Stikbakke
9b9cb30ae1 feat(coding-agent): add pi auth login and logout
pi auth login <provider> logs in from the shell without starting a
session, like pi mcp login. Account sign-in is the default and takes
the provider's browser method; when the provider has another method,
it prints a hint to rerun with --no-browser. --no-browser skips the
browser method and uses the other one, such as device code, asking
only when there are several. --api-key saves an API key instead and
cannot be combined with --no-browser. After logging in, it fetches the
provider's model catalog, and for Radius it points the Radius MCP
server in the global mcp.json at the login without asking.

pi auth logout <provider> removes the stored credentials like /logout
and succeeds when nothing is stored. For MCP servers that use provider
credentials, pi mcp login and logout now refer to these commands
instead of /login and /logout.

/login shares the check for the Radius MCP server entry and the web
URL helpers with the new command.
2026-11-05 12:07:14 +01:00
Cristina Poncela Cubeiro
e5f3b41506
feat(storage): replication (#11696)
Adds experimental session sync (`@earendil-works/pi-durable/sync`).

Devices upload and pull a session's commits through a remote, reading
them from the Chord store's outbox and applying them with `applyBatch`.
If two devices continue the same session, the one that uploads second
forks to a new session.

I took out the extra sequence indexes from the earlier version of this
branch, since the Chord outbox already persists every commit, so this no
longer touches storage.

---------

Co-authored-by: Armin Ronacher <armin.ronacher@active-4.com>
2026-11-05 12:03:16 +01:00
Mario Zechner
d7a988d18d feat(chord): a call's answer follows its state
Before a provider sends or forwards a call's result or error, it sends the origin's
waiting state, past the window if it is full, so a resolved call's changes are
applied on the page. TuiScope.optimistic shows a value while calls are pending; the
guide shows useOptimistic over a Chord reference; example 63.
2026-11-05 10:45:40 +00:00
Cristina Poncela Cubeiro
af79b0c3d7
feat(schema): TypeBox parity for extra types (#11833)
Adds unsafe<T>(), recursive<T>(), Validator.validate() (check plus
errors, no coercion), bigint(), equal(), validation and coercion of
TypeBox's Type.BigInt/Type.Undefined, and Static for TypeBox's Enum,
Tuple, BigInt and Undefined. Also fixes a required property set to
undefined being rejected when its schema accepts undefined, as TypeBox
allows.
2026-11-05 11:36:38 +01:00
Mario Zechner
9ff0ebfd05 docs(chord): decisions: restart-only reload with old work on old code, answers follow their state, app authoring rules 2026-11-05 10:31:23 +00:00
Christian Klotz
7a52c2108d
feat: release version tags on pkg.earendil.com (#11686)
## Problem

`scripts/release.mjs` pushes `Release vX.Y.Z` and `Add [Unreleased]
section…` to main in one push, then pushes the tag. CI builds only the
pushed head, so the tagged release commit is never built and
pkg.earendil.com has no build of a release. Example: for v1.4.0, v1.3.0
and v1.2.0 the tagged commits have Build Binaries and Nix runs, but no
CI run.

Internal users want to install a release from pkg.earendil.com as soon
as it is tagged, without waiting for the npm release.

## Change

- `ci.yml` also runs for `v*.*.*` tags (the pattern `nix.yml` uses), so
the tagged commit is built and packed.
- `preview-packages.yml` also publishes passing CI runs of release tags:
`POST /_/releases` keeps the packages for good under the tag (it never
moves), then the `latest` ref moves to it. Pre-releases (`v1.2.3-rc.1`)
are released without moving `latest`.
- `scripts/preview-packages.mjs publish` decides what a push run
publishes (the workflow passes the run's branch and conclusion), reading
the GitHub API with `fetch` before anything is uploaded. A tag push's
`head_branch` is the tag's name, and a branch or tag can carry its own
copy of CI, so:
- every push run must be for a commit on main, comparing against main's
resolved SHA (a bare `main` in compare would resolve to a tag named
`main`);
- a release tag must match `^v\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$`, have
passing CI (CI packs before its tests run), and point at the commit,
dereferencing annotated tags, also nested;
  - other names publish nothing.

This also closes an existing hole: a tag named `main` on an unreviewed
commit could move the `main` ref.
- A release happens after the uploads and before moving refs, so
`latest` never points at a failed release. The job summary lists the
release's install commands.
- A passing push run whose CI artifact expired (3 days) fails with
"re-run CI" instead of passing silently, so a release tag cannot go
unreleased unnoticed.

## Before / after for a release

| | Before | After |
|---|---|---|
| CI of the tagged commit | none | build, test, pack |
| `pkg.earendil.com/<package>@vX.Y.Z` | 404 | kept for good |
| `…@<release sha>` | 404 | kept for good |
| `…@latest` | none | moves to each release |
| npm, binaries, pi.dev, Nix `stable` | unchanged | unchanged |
| main and PR previews | unchanged | unchanged |

Tag CI runs trigger Publish Model Catalog and Nix, which skip them
(`head_branch != main`).

## Before merging

- [x] Deploy pkg.earendil.com with `POST /_/releases` (earendil-pkg
`7955990`); until then a tag run fails at the release call and `latest`
does not move.

## Recommended (not required by this PR)

Add a ruleset restricting creation, update and deletion of `v*` tags to
maintainers / the release process. The checks above already keep
unreviewed code out (every push run must be for a commit on main), but
anyone with write access can push a `v*` tag on any main commit. On
pkg.earendil.com that release is kept for good, takes the version number
(the real release then gets `409` there) and moves `latest`, with no
approval gate like npm's `npm-publish` environment. Build Binaries (npm)
and Nix (`stable`) also act on `v*` tags today.

## Checks

`npm run check`; `node --test scripts/preview-packages.test.mjs` (10
tests, under 2s, against a fake server for pkg.earendil.com and the
GitHub API with raw API responses). Each of these mutations fails a
test: bare `main` in compare, wrong API field names, no on-main check,
no tag target check, one-level tag dereference, `latest` for
pre-releases, unanchored or loose tag pattern, no CI conclusion check,
dropping `identical`. actionlint reports only the existing `queue:`
complaint.
2026-11-05 10:01:01 +00:00
robopipi[bot]
5732bac060
fix(coding-agent): cycle to the only model in scope when the active model is outside it (#11832)
Ctrl+P did nothing when the scope held exactly one model and the active
model was a different one. It showed "Only one model in scope" and kept
the current model, because `_cycleScopedModel` returned early for any
list with one entry. `_cycleAvailableModel` had the same early return,
so it also failed when the active model was no longer available and one
other model was.

A second bug had the same cause. When the active model was not in the
list, the code treated it as if it were at index 0, so cycling forward
skipped the first scoped model. With `--models A,B` and another model
active, Ctrl+P went to B.

This change adds a small `getNextCycleIndex` helper that both functions
now use:
- When the active model is not in the list, cycling forward selects the
first entry and cycling backward selects the last.
- It returns `undefined` (the "Only one model…" message) only when the
list is empty, or when its single entry is already the active model.

**Testing**
- Added a regression test to
`test/suite/agent-session-model-extension.test.ts`. It covers a
one-model scope, a repeated cycle once that model is active, and forward
and backward cycling into a two-model scope from outside it. The test
fails without the change ("expected undefined to be 'faux-2'") and
passes with it.
- `npm run check` passes.
- Manual check: `pi --model anthropic/claude-sonnet-4-5 --models
anthropic/claude-opus-4-5`, then Ctrl+P.
  - Before: "Only one model in scope".
  - After: "Switched to Claude Opus 4.5".
- With `--models anthropic/claude-opus-4-5,anthropic/claude-haiku-4-5`,
Ctrl+P now selects opus instead of haiku.

Fixes #11806

Requested by @davidbrai

Co-authored-by: triagent <triagent@localhost>
Co-authored-by: David Brailovsky <davidbrai@gmail.com>
2026-11-05 10:39:18 +01:00
David Brailovsky
469d8651b5 fix(durable): raise timeout of seeded SqliteStorage batch commit tests 2026-11-05 09:50:00 +01:00
David Brailovsky
8eddfe82ff fix(ai): advance fake timers until Copilot login settles in OAuth test
models.login loads the OAuth flow module through a URL import since 5c6ce2b32, which takes real I/O under vitest. A single 1000ms fake-timer advance ran before the device flow started, leaving the poll wait unadvanced and the test hanging.
2026-11-05 09:45:52 +01:00
Cristina Poncela Cubeiro
c5d98a78ed
feat(ui): script to build bundled (#11801)
We share the bundled UI built from p2 for Radius. This PR adds a script
to bundle it, and this gets uploaded to R2 on every green commit to
main.
2026-11-05 09:42:35 +01:00
David Brailovsky
5f6198c397 remove temp issue triage scripts 2026-11-05 08:32:50 +00:00
Vegard Stikbakke
a013448fb5 feat(coding-agent): merge consecutive cache warming notices into one line
Back-to-back cache warms update a single 'Cache warmed: $<total>' line with the cumulative cost instead of adding one line per warm. Each warm is still persisted as its own usage entry.
2026-11-05 09:24:57 +01:00
Mario Zechner
90e2040468 fix(p2): session workers load OAuth flows and Bedrock from the worker base
The worker registers pi-ai's lazy OAuth and Bedrock loaders, so they are chunks of the
snapshot instead of pi-ai source files loaded at run time. The worker entry imports
chord/workers/child and host-packages.ts instead of plugins.ts: a fork loads no esbuild,
no server API and no provider SDK (690 to 301 modules on the phone).
2026-11-05 08:49:18 +01:00
Mario Zechner
7ac1a9e3a0 feat(chord): split the worker base at dynamic imports; workers/child entry
buildWorkerBase bundles with esbuild code splitting into one private directory: the entry
and its static chunks load at each fork, a lazily imported module and the packages it
imports only when imported. @earendil-works/chord/workers/child exports the worker's side
(workerMain, processLock) without esbuild.
2026-11-05 08:49:17 +01:00
Mario Zechner
da95cd79c6 feat(ai): lazy OAuth flow and Bedrock loaders for code-split Node bundles
registerLazyOAuthFlows (@earendil-works/pi-ai/lazy-oauth) and registerLazyBedrockProvider
(@earendil-works/pi-ai/lazy-bedrock-provider, built on setBedrockProviderLoader) load the
flows and Bedrock through literal dynamic imports, so a splitting bundler keeps them inside
the bundle as lazy chunks. The variable-specifier default is unchanged.
2026-11-05 08:49:17 +01:00
Mario Zechner
5be85f3be3 fix(p2): a report's origin scans past mid-run error answers and compaction markers 2026-11-05 07:38:16 +00:00
Mario Zechner
fe68bf23e8 feat(p2): subagent messages start with a <sender kind="subagent"> block instead of a [subagent …] line 2026-11-05 07:23:22 +00:00
Mario Zechner
d57c2d2191 fix(p2): guest turns may use the terminal tool again, as decided (reverts 2b78884e3) 2026-11-05 00:05:40 +00:00
Mario Zechner
2b78884e3a feat(p2): the terminal tool needs a turn the owner started 2026-11-05 00:03:50 +00:00
Armin Ronacher
5c6ce2b32f fix(ai): resolve variable lazy imports against the module URL
Chord's worker base inlines pi-ai into a bundle in a temporary directory. esbuild keeps the variable-specifier imports of the OAuth flows and the bedrock API, so Node resolved them against the bundle and failed (radius OAuth: Cannot find module .../chord-worker-*/radius.ts). Resolving against import.meta.url, which Chord defines per source module, finds the real file.
2026-11-05 00:39:49 +01:00
Armin Ronacher
80cb316cd7 fix(p2): report TUI sandbox crashes with their stack
The launcher printed one message for both a heap overrun and an uncaught exception. It now says which, and prints the sanitized, bounded stack of an uncaught exception or a throwing message handler after restoring the terminal. The TUI bundle keeps names so frames are readable.
2026-11-05 00:35:13 +01:00
Armin Ronacher
6bfea62e4b fix(p2): TUI transcript render survives a sync from its own callbacks
onTop/onMissingAnchor/onVisibleGap load history, which syncs the transcript synchronously through runtime.changed(). The gap loop then indexed the new order against the old line starts and crashed the TUI sandbox when switching sessions. Render now works on the views it started with.
2026-11-05 00:35:13 +01:00
Petr Houška
b2cb629237
fix(coding-agent): wait for a started OAuth refresh to be saved before exit (#11812)
Fixes #11692.

## Problem

In RPC mode the startup catalog refresh runs in the background and also
refreshes an expired OAuth credential. If pi exits after the token
request went out but before the new token is written, the provider has
already spent the old single-use refresh token and auth.json keeps only
that one. The next request fails with `invalid_grant`.

Trace: `pi --mode rpc` starts, `get_state` is answered, catalog refresh
sends the token request, the client sends SIGTERM (or closes stdin),
`shutdown()` runs `runtimeHost.dispose()`, which does not know about the
refresh, and the process exits. Same for print and interactive mode, and
for a prompt-time refresh cut off by an exit.

## Fix

- `RuntimeCredentials.modify()` tracks every pending modification (same
idiom as the MCP `track()` / `McpAuthProvider.settled()`).
- `RuntimeCredentials.settled()` /
`ModelRuntime.settleCredentialWrites()` wait for them.
- `AgentSessionRuntime.dispose()` waits for them. All modes exit through
`dispose()`. No extra timeout: the OAuth refresh (15 s) and the
auth.json lock wait (30 s) are already bounded, and a second signal
still exits at once.

Not changed: the catalog refresh still refreshes expired OAuth
credentials, also for providers whose catalog does not use them (the
pi.dev catalog for `openai`, `anthropic`, ...). Skipping that would need
a per-provider "catalog needs auth" signal in pi-ai; it is optional now
that exits wait.

`docs/rpc.md` now says that pi waits for pending work before exiting.

## Testing

- `test/runtime-credentials.test.ts`: `settled()` waits until a pending
modification is written, and resolves after a failed one.
-
`test/suite/regressions/11692-dispose-waits-for-oauth-refresh.test.ts`:
`dispose()` waits until the token from a background catalog refresh is
saved. Fails without the fix.
- Real process: `pi --mode rpc` with an extension OAuth provider
(expired credential, 400 ms refresh), SIGTERM about 100 ms after the
token request started. Without the wait: 3 of 3 runs kept the spent
refresh token. With the fix: 3 of 3 saved the new one (exit 143).
Closing stdin instead of SIGTERM: new token saved, exit 0.
- `npm run check`; `test/rpc*`, `test/print-mode`,
`test/model-runtime*`, runtime and regression suites pass.
2026-11-05 00:32:41 +01:00
Mario Zechner
07c9e5b6fd feat(p2): tools declare who may call them and where, turn origin from the run's inputs, agents give their own tools
Some checks failed
CI / build-check (push) Failing after 14m3s
CI / test (coding-agent) (push) Has been cancelled
CI / test (durable) (push) Has been cancelled
CI / test (other) (push) Has been cancelled
CI / mcp-conformance (push) Has been cancelled
- P2Tool: origin (any|owner) and scope (any|main|subagent) on the tool; the worker's
  Durable registry checks them before each call (guardTool), no tool checks itself.
  owner: location tools, reload_plugins, conversations, ports, MCP tools.
  main: subagent, rename_session, conversations, ports, reload_plugins. subagent: team.
- originOf reads pi.live.run.inputs through tx.submission: the owner's only if every
  input of the run is. Agent-written inputs record the writing turn's origin: subagent
  tasks and one-shot prompts, team messages, reports (answerOrigin), peer messages.
  No peer origin any more.
- Subagents and one-shot agents get their caller's tools (default all, tools narrows),
  except scope main; with codemode the script-only tools; subagents get team. One-shot
  agents keep codemode and agent. A send takes away what the caller can no longer give.
2026-11-04 23:28:14 +00:00
Petr Houška
06aa6563e4
fix(coding-agent): report the shell exit code when bash times out after the shell exited (#11813)
Fixes #11613.

## Problem

The bash tool waits for stdout/stderr to close, not only for the shell
to exit (#5303). A background process that inherited those pipes and
keeps writing keeps the tool waiting after the shell exited. When the
timeout then hits, the tool reported only `Command timed out after N
seconds`.

Example, timeout 3s:

```bash
bash -c 'while true; do echo tick; sleep 0.05; done' & sleep 1; echo boom >&2; exit 1
```

The shell exits with code 1 after ~1s. The tool still times out at the
deadline, because the loop is still writing to its output. Both facts
are true: the command failed with code 1, and something it started was
still running at the timeout. The old message dropped the first, so the
model read a failed command as a slow one and retried with a longer
timeout, which never helps here.

## Fix

The timeout stays a timeout, since the tool did hit its deadline while
background processes were still writing. When the shell had already
exited before the deadline, the message now also says so:

```
Command timed out after 3 seconds. The shell exited with code 1 after 1.1 seconds, but background processes it started kept writing output until the timeout.
```

A shell still running at the deadline keeps the plain `Command timed out
after N seconds`.

The local backend throws a module-private `ShellTimeoutError` with the
message `timeout:<seconds>` as before, so custom `BashOperations` that
throw `timeout:N` are unaffected.

## Behavior per platform

- Linux and macOS: the shell runs in its own process group (`detached:
true`). At the deadline `kill(-pgid)` still reaches the background
processes after the shell exited, the pipes close, and the tool returns
at the deadline. Tested on Linux; macOS uses the same mechanism and
matches the macOS repros in the issue.
- Windows: still not fully solved. At the deadline `taskkill /T /PID
<shell>` fails with `process not found`, because the shell pid is gone.
Walking parent pids does not help: Git's `bin\bash.exe` is a launcher
for `usr\bin\bash.exe`, and the orphaned loop's parent is that
already-exited inner bash, not the pid pi holds. The tool keeps reading
until the output pauses for 100ms. Measured with Git Bash: the message
is now correct, but the tool returned after 14s instead of 3s; with a
loop that never pauses it never returns. This PR only fixes the
reporting.

## Same pattern elsewhere

- `packages/durable/src/env/node.ts` (`NodeExecutionEnv.exec`): same
code, same false `timeout:N` after the shell exited. Same Windows
limitation.
- `packages/coding-agent/src/core/exec.ts` (`pi.exec` for extensions):
the timeout sends SIGTERM/SIGKILL to the direct child only, no process
group. A descendant that keeps writing keeps the call waiting past the
timeout on every platform, and the result has `killed: true` even when
the child had already exited with its own code.
- `packages/coding-agent/src/core/package-manager.ts` (capture commands
for git/npm): waits for `close` and kills only the direct child on
timeout, so a descendant holding the pipes makes the timeout ineffective
on every platform, even when the descendant writes nothing.
- `packages/mcp/src/transports/stdio.ts`: on Windows, closing a server
that already exited skips taskkill, so children holding its pipes
survive. The code documents this as a known limitation.

Possible follow-up for Windows and the cases above: once the deadline
passed, stop waiting for the pipes (destroy our ends). On Windows this
also ends most writers, which die from a broken pipe (tested with Git
Bash).

## Tests

- `test/suite/regressions/11613-bash-timeout-after-shell-exit.test.ts`
(Unix only; it would hang on Windows for the reason above): background
writer after the shell exits with code 3, and a shell still running at
the deadline. The first case fails without the fix.
- Checked manually on Windows 11 with Git Bash through the built bundle:
both messages are as above.
- `npm run check`, `test/tools.test.ts`, the #5303 and #5208
regressions, `bash-close-hang-windows`, `powershell-tool`,
`shell-kill-process-tree`.

This PR description is AI-generated.
2026-11-04 23:47:16 +01:00
David Brailovsky
4bb837c960 fix(p2): name the TUI bundle with a sourceURL so stack traces stay small
The sandbox imports the bundle from a data: URL, which put the whole base64
bundle (~1.4 MB) into every stack frame. Chord formats a released reference's
error stack, so /agents -> subagent and /clone exhausted the worker's 96 MB heap.
2026-11-04 23:15:51 +01:00
Petr Houška
3a7bb6f48c
fix(coding-agent): stream the read tool instead of loading whole files (#11750)
## Problem

`read` loads the whole file before it applies `offset`/`limit`:
`ops.readFile` -> `toString("utf-8")` -> `split("\n")`. A one-line read
still holds the whole file three times: as a Buffer, as a string and as
an array of lines.

Measured on main with a 200 MB log (2.36M lines):

- `offset: 1, limit: 1` peaks at 638 MB RSS. With
`--max-old-space-size=256` it crashes in `Runtime_StringSplit` (OOM).
- Files over about 512 MB fail with `Cannot create a string longer than
0x1fffffe8 characters`. Files over 2 GB fail with
`ERR_FS_FILE_TOO_LARGE`.
- Aborting only rejects the promise; the read keeps running in the
background.

## Solution

- `ReadOperations` gets a new optional `createReadStream(absolutePath, {
signal })`, which returns an `AsyncIterable<Uint8Array>` (a Node
`Readable` works). The default local operations open the file once,
`fstat` the handle, and stream from it. Like `fs.readFile`, they stop a
regular file at its size when opened: text appended during the read is
not counted, and a file that keeps growing still finishes. A file that
reports size 0 (such as `/proc` files) and other file types (FIFOs,
devices) are read until EOF, as `fs.readFile` reads them. No existing
member changed.
- The text path decodes the chunks with a streaming UTF-8 `TextDecoder`
(non-fatal, `ignoreBOM: true`). That matches `Buffer.toString("utf-8")`,
including invalid bytes and a UTF-8 byte order mark kept as U+FEFF.
- It counts every line but stores only the selected lines `truncateHead`
can show. Storing stops once the stored lines are past
`DEFAULT_MAX_LINES` or `DEFAULT_MAX_BYTES`, plus one more line, and the
existing `truncateHead` runs on the stored lines.
`totalLines`/`totalBytes` are then patched from the counts, as grep and
find do on main.
- The extra line is needed because `truncateHead` drops a trailing empty
line. Example: 2001 short lines, then an empty line, then more text. If
storing stopped at the empty line, the stored text would end in `\n`.
`truncateHead` would count 2000 lines and report "not truncated", but
the full text is truncated by lines.
- A line over 50KB is never stored whole; only its size is tracked. A
first line of 1 GB therefore still gives today's `[Line 1 is ...,
exceeds 50.0KB limit ...]` notice.
- Abort: the signal is passed to the stream, and the loop also checks it
between chunks. Leaving the loop closes the stream.
- The whole-file path stays unchanged for custom operations without
`createReadStream`, and for fractional `offset`/`limit` or negative
`limit`. Array slicing defines what those odd ranges select; for
example, `limit: -1` selects all lines but the last.
- Images are unchanged.

Output is the same as main's, byte for byte, including line counts,
notices, details (including key order) and errors. A file appended to
during the read gives the same output too: both read a regular file up
to its size when opened. Out of scope: the trailing-newline off-by-one
in line counts from #7329 is kept as is.

## Testing

- `test/read-streaming.test.ts` keeps a copy of the old text algorithm
as the reference. It compares the streaming path against it on text,
details and error messages, with chunks of 1 and 4096 bytes and offsets
and limits around the boundaries. The cases are: empty file, trailing
newline or not, CRLF, multi-byte characters and a UTF-8 BOM, 2001 lines,
and a first line over 50KB. There are also edge cases where the line or
byte cap falls exactly on an empty line, and the 50KB + trailing newline
case that `truncateHead` reports as `truncatedBy: "lines"`. Further
tests:
  - details key order
  - multi-byte characters split across chunks, and invalid UTF-8
  - the `readFile` fallback, including fractional and negative ranges
  - local files through the default stream, including EISDIR
  - abort: the stream is closed and no more chunks are pulled
- a local file appended to mid-read (a decode hook in the test appends
after the first chunk): `limit: 1` still says `[1 more lines ...]`. This
test fails with an unbounded `fs.createReadStream`.
- bounded memory: a child process with `--max-old-space-size=64` reads a
200 MB single line, generated in memory as 64 KB chunks from a custom
`createReadStream`, and must print the size notice. It takes about 0.8 s
and uses 17 MB of heap. If over-long lines are stored whole, it runs out
of memory. 64 KB chunks matter here: V8 keeps decoded strings of 1 MB
outside the heap limit, so with 1 MB chunks the test would not catch
that regression.
- With main's `read.ts`, 5 of the 8 tests fail: matrix, key order,
chunked decoding, bounded memory and abort. The fallback, local-file and
append tests pass on main because they check behavior that did not
change. Removing the one-line-past-the-cap rule, or the
trailing-empty-line adjustment of `totalLines`, makes the matrix fail.
- `test/tools.test.ts`, `read-line-range-display`,
`tool-execution-component`, `block-images` and `image-resize-callers`
pass. `npm run check` is clean.

Measured with an ad-hoc script on a 200 MB log. Baseline RSS after
loading modules is about 121 MB.

| Case | main | this PR |
| --- | --- | --- |
| offset 1, limit 1 | 1028 ms, 638 MB peak RSS | 478 ms, 131 MB |
| offset 2000000, limit 10 | 926 ms, 637 MB | 486 ms, 132 MB |
| no offset/limit (counts all lines) | 1561 ms, 1010 MB | 524 ms, 132 MB
|
| offset 1, limit 1, 256 MB heap | OOM crash | 131 MB |
| 1.1 GB single-line file | string length error | 2.2 s, 130 MB,
line-size notice |

Remaining difference: custom operations that only implement `readFile`
keep the whole-file memory use, and their reads continue after an abort,
as before.

This PR is AI-generated.
2026-11-04 23:14:52 +01:00
Mario Zechner
43fb4e3106 feat(p2): <sender> block for the model, peer messages as p2.peer-message entries, legacy speaker reader removed 2026-11-04 22:07:38 +00:00
Mario Zechner
9ca159208a feat(durable): Tx.submission(id) reads a submission record by ID 2026-11-04 21:57:58 +00:00
Mario Zechner
5ff8c02708 feat(chord): workers start from the base code the server booted with
createWorkers bundles the worker entry once (esbuild, Node builtins and installed
packages external) and forks every worker from that snapshot, so an edit of the
app's source while the server runs reaches workers only through a restart. The kit
watches the bundle's inputs as base code. p2 creates its session workers with
SessionWorkers.create and keeps the worker base's inputs as a dev restart baseline.
2026-11-04 21:18:17 +00:00
Petr Houška
df0d88aac1
fix(coding-agent): keep model selector selection when the catalog refresh lands (#11810)
Opening the selector starts a background catalog refresh. When it
finished, the selection jumped back to the current model, or to the top
row with a search query, discarding any browsing done in the meantime.

Pass the selected model into filterModels() so the refresh keeps it
selected by provider and ID. If it is gone, the existing fallback
applies. The index is chosen once and the list is rebuilt once.

Based on #11758.

closes #9109

---------

Co-authored-by: bermudi <github.igizp@dabg.uk>
2026-11-04 22:15:02 +01:00
Armin Ronacher
474416d70b feat(p2): subagents can use code mode
codemode is choosable for subagents and in their default tools while the
codemode setting is on; one-shot agents never get it. A subagent with code
mode gets the script-only tools (MCP, terminal, agent, models.*) by name in
its Durable tools array, at spawn/configure and from the worker when the
registry's script-only tools change.
2026-11-04 21:56:56 +01:00
Armin Ronacher
617d0ef549 fix(p2): archiving closes an outdated terminal host's terminals through the viewer protocol 2026-11-04 21:56:56 +01:00
Armin Ronacher
47005dbe36 feat(p2): terminal tool for the agent, tmux-style argv, code mode only
The agent drives the session's terminals (shared with the owner's panel) with
tools.terminal({ command, args }): new-window, list-windows, send-keys, read,
capture-pane, kill-window, with waits (quiet, exit, /regex/) and plain-text
output. The terminal host keeps each terminal's last MiB of output and the
agent's read cursor, answers calls with ids, and does keyed calls once, so the
tool is replay-safe. AgentTerminals serves the session's current worker on its
environments; archiving closes a session's terminals. Without code mode the
worker registry installs tool_search once for all deferred tools.
2026-11-04 21:56:56 +01:00