diff --git a/apps/p2/docs/handoff.md b/apps/p2/docs/handoff.md
index 9bdb83cb9..230f9be81 100644
--- a/apps/p2/docs/handoff.md
+++ b/apps/p2/docs/handoff.md
@@ -173,6 +173,7 @@ From `AGENTS.md` and this project's history:
| `README.md`, `DEV.md` | running, deployment behind a proxy, TUI sign-in and isolation |
| `docs/p2-notes.md` | decisions, configuration, plugin-by-plugin notes |
| `docs/p2-tui-port.md` | TUI design, as-built notes, open phases |
+| `docs/optimistic-ui.md` | optimistic UI: the pattern for actions, pending states and loads (web and terminal) |
| `docs/workflows.md` | code mode workflows with one-shot agents |
| `docs/tree.md` | `/tree`: branches of the main conversation as Durable forks |
| `docs/remote-envs.md` | SSH environments |
diff --git a/apps/p2/docs/mcp-durable.md b/apps/p2/docs/mcp-durable.md
index 4792570eb..fab382895 100644
--- a/apps/p2/docs/mcp-durable.md
+++ b/apps/p2/docs/mcp-durable.md
@@ -225,8 +225,8 @@ export class RemoteProcess {
(`mcpNamespace(name)|url`). The refresh lock is a promise chain per server in the process, not `proper-lockfile`: one
p2 server owns a data directory (process lock), and nothing else reads this file.
- **Flows:** `sign-in.ts` (`SignIns`, a native of the manager), in memory, one per server (a new one replaces it),
- 10 minutes each; listed (`signIns`: id, server, `via: "loopback" | "route"`, `waiting | failed`, error) in the
- manager's `Listed` document. Not a Chord flow object: the manager's methods are `beginSignIn`, `completeSignIn`
+ 10 minutes each; listed (`signIns`: id, server, `via: "loopback" | "route"`, `waiting | failed`, error) as the
+ manager follows them (`self.follow`: a method's change is listed when it answers). Not a Chord flow object: the manager's methods are `beginSignIn`, `completeSignIn`
(callback route), `pasteSignIn`, `cancelSignIn`, `signOut`, `probe`. A done flow disappears; the instances of its
server restart (`Instances.reconnect`). A pre-registered client (`oauth.clientId`) always uses loopback.
- **Links by server, not flow:** `GET /mcp/sign-in/` begins (or replaces) the server's flow, so the link is
diff --git a/apps/p2/docs/optimistic-ui.md b/apps/p2/docs/optimistic-ui.md
new file mode 100644
index 000000000..9c6d49aa9
--- /dev/null
+++ b/apps/p2/docs/optimistic-ui.md
@@ -0,0 +1,137 @@
+# Optimistic UI in p2
+
+Every action whose effect the user waits to see shows that effect at once: the switch flips, the row is renamed, the
+message is in the transcript. This page is the pattern p2's plugins follow, on the web and in the terminal, and the
+reference for plugin authors.
+
+## Why it works without flicker
+
+Two rules make an optimistic value safe to drop the moment its call settles:
+
+1. **A method that should feel instant awaits its own effects** and keeps them on the host that answers (Chord's
+ guide; decision "App authoring rules confirmed by the p2 audit"). `Sessions.rename` commits the catalog before it
+ returns; `ChatMode.set` returns once the followed Durable document changed.
+2. **A call's answer follows its state** (Chord decision of 2026-11-05): before a host sends a `result`, it sends the
+ page the `state` it committed. When the call resolves on the page, the new value is already there.
+
+So the page shows a guess while the call is in flight and simply forgets it when the call settles: on success the
+real value has replaced it in the same frame; on failure the real value is the old one, so the guess rolls back by
+itself, and the error is shown. Nothing on the page reconciles a guess with the server's value, compares ids or
+waits for state after an answer.
+
+A method that returns before its effect (a background job, a native that reports later) breaks rule 1: its guess
+would vanish before the real value arrives. Such work shows a **pending state** instead (below), never a guess of
+its outcome.
+
+## Web: `useOptimisticCall`
+
+`useOptimisticCall(value, apply, onError)` (`src/runtime/web.tsx`, from `@p2/app/v1/web`) is React's
+`useOptimistic` plus a transition: it returns `[shown, run, pending]`.
+
+```tsx
+// plugins/chat-mode/web.tsx: the chat mode chip
+const { fail } = useComposerState(); // the app's toast
+const [{ enabled, name }, guess] = useOptimisticCall>(
+ { enabled: useReadable(mode?.enabled) ?? false, name: useReadable(mode?.name) ?? DEFAULT_AGENT_NAME },
+ (shown, change) => ({ ...shown, ...change }),
+ fail,
+);
+// ...
+ guess({ enabled: !enabled }, () => mode.set({ enabled: !enabled }, BACKGROUND_CONTEXT))}>
+```
+
+- `value` is what the reference's state says now (`useReadable`); `apply(value, guess)` is a pure reducer. Guesses
+ in flight are applied over the current value in order, so a state change arriving meanwhile is not hidden.
+- `run(guess, call)` shows the guess at once and keeps it until `call()` settles. Its errors go to `onError`
+ (usually `useComposerState().fail`, the app's toast; a sheet with its own error line passes its setter).
+- `pending` is true while a call of this component is in flight: the label of a button doing long work.
+- The guess lives in the component that shows the value. When the value shows in several places, put the actions
+ where the value is rendered: the Session sheet is rendered by the sidebar (`plugins/sessions/web.tsx`), which owns
+ the rows, so a rename, pin, archive or restore changes the list at once. Other views (the top bar's title) show the
+ state that arrives with the answer.
+
+Reducers in p2 to copy from: a list edit by id (`applyRow` in sessions), add/remove/flip (`applyPort` in
+`plugins/ports/PortsPanel.tsx`), save/delete (`applyEnv` in `plugins/envs/EnvsPanel.tsx`), a replaced value (the
+model choice in `plugins/chat/web.tsx`, the provider login state in `plugins/login/web.tsx`).
+
+## Terminal: `scope.optimistic(read, reduce?)`
+
+The terminal has no React; Chord's `TuiScope.optimistic` is its `useOptimistic`, and p2's `TuiScope` offers it
+(redrawing the runtime): `scope.optimistic(read, reduce?)` returns `[read, add, pending]`. While calls are in flight
+the reader shows `reduce(current, action)` applied for each action in order (without a reducer the last action is the
+value), until every call added settles. `add(action, call)` returns the call: as with a React transition, its failure
+is the caller's, and p2 shows it with `runtime.perform` (`ctx.perform` in commands and overlays).
+
+```ts
+// plugins/chat-mode/tui.ts
+[this.shownEnabled, this.switching] = this.scope.optimistic(() => this.enabledState);
+get enabled(): boolean {
+ return this.shownEnabled();
+}
+toggle(): void {
+ const enabled = !this.enabled;
+ this.runtime.perform(
+ this.switching(enabled, owner.set({ enabled }, BACKGROUND_CONTEXT)).then(() =>
+ this.runtime.notify(enabled ? "Chat mode on" : "Chat mode off"),
+ ),
+ );
+}
+
+// plugins/sessions/tui.ts: a reducer applies row edits by id
+[this.shownRows, this.editing] = this.scope.optimistic(() => this.received, applyRow);
+```
+
+- A surface keeps the server's value as received and reads it through the optimistic reader.
+- `pending()` shows long work (the `/compact` indicator); a reader whose value only changes on `add` and settle is a
+ cheap input for a surface that rebuilds on change (the chat transcript's messages in flight).
+- Overlays and commands get a scope from their context: `ctx.scope()`, disposed when the overlay closes
+ (`/agents`, `/ports`, `/mcp`).
+
+## What has no guess: server-made values and long work
+
+- **Client-chosen ids** make creation instant. Send on the new-session screen shows the message at once
+ (`plugins/chat/session-start.ts`); the page chooses the session's ID (`Sessions.create({ id })`), so a creation
+ retried after a lost answer answers the same session, and holds the new session's main conversation while `create`
+ runs, so its worker starts beside the creation, not after it. The messages then move to that conversation's outbox
+ and the session opens; a refused creation puts them back into the composer. A sidebar folder's "+" opens the same
+ screen in that folder. `/new`, `/clone` and `/fork` in the terminal choose the ID (`id`, `copy`) and open the session
+ at once (it shows loading while a copy's files are copied). Each message has its own `requestId`.
+- **Server-made ids** are not guessed (`Ports.add`: the forward id is part of its address; `ProviderLogin.start`:
+ the attempt): the page shows a pending row or screen, the new forward with its port and name but no address and no
+ actions, the login sheet's spinner until the provider's first prompt arrives.
+- **Long work** is a job on the server; the page shows that it runs and how it ended, never a guessed result.
+ Session lifecycle operations (`Sessions.operations`: a move, a clone, a fork, an archive) mark their row in the
+ sidebar ("Moving…", "Copying files…") and in the terminal's selector; a failed one stays on its row with its error
+ and Dismiss (`dismissOperation`; `/dismiss-failed` in the terminal), and the top bar says "Moving…" from the tap
+ until the move is done (a move the busy worker refused shows in the toast too). Retry shows "Retrying…" until the
+ worker runs, or its error; "Rebuild search index" (`Search.rebuilds`, `/reindex`) shows it running until done or
+ failed; "Save and test" shows "Working…"; `/compact` shows the compaction indicator at once.
+- **Sending** is optimistic on both sides: the web's outbox (`plugins/chat/outbox.ts`) and the terminal's send show
+ the message at once (in the queue while a run is busy, in the pending area while the conversation connects) and
+ clear the editor; a refused one comes back to the editor with its error. A retry after a lost answer reuses the
+ request ID, so it is admitted once.
+
+## Load: no waterfalls
+
+The same instinct applies to loading: hold what a view needs in parallel, from values the page already has.
+
+- **Cached owner flag:** `useOwner()` remembers whether this browser is the owner across starts; `useCanWrite(session)`
+ adds a guest's grant. Views that are the owner's (unread marks, search, models, the workspace button, voice) hold
+ at once instead of after `Access` is live. The terminal starts with the role the launcher was admitted as.
+- **Holds with the view:** `SubagentControl`, `Drafts` and the file index hold when the view opens (who may write is
+ known), not after `Chat` is live or the subagent list names the child; `/tree` holds one `SessionTree` per session
+ for all its actions.
+- **Short links:** `/s/` opens the session's main conversation as this device last saw it (`knownMain`), so the
+ page holds the right conversation first instead of conversation 1 and a redirect.
+- **No waiting for state after an answer:** a share link is read from `links` when `Sharing.link` resolves.
+
+## Tests
+
+- `test/p2/ui-optimistic.test.ts` (fixture `test/fixtures/optimistic.ts`): in Chromium, the guess and the pending
+ state while a call is held open, the server's value after it passes, the rollback and the error after a refusal.
+- `test/p2/tui-optimistic.test.ts`: in the terminal, a session rename and pin shown while their calls are held open
+ and a refused rename rolled back with its error; a sent message shown at once and returned to the editor when
+ refused; a running operation marking its row, a failure announced and dismissed.
+- `ui-drawer.test.ts` (a new session shows its first message and attachment at once; a folder's "+"),
+ `session-start.test.ts` (one ID across retries), `tui-location.test.ts` and `tui-p4.test.ts` (`/new` opens the
+ client-chosen session), `tui-fork.test.ts` (`/fork`, `/clone` open the copy).
diff --git a/apps/p2/docs/p2-notes.md b/apps/p2/docs/p2-notes.md
index 76158914e..ab009ad48 100644
--- a/apps/p2/docs/p2-notes.md
+++ b/apps/p2/docs/p2-notes.md
@@ -258,7 +258,7 @@ The launcher polls the bundle version every second. A changed hash prepares a ne
active; once ready, it copies bounded UI state (selection, drafts, send retry IDs, display toggles, reading position),
swaps the fixed-origin connection and terminates the old worker. Failed preparation retains the old worker.
`/reload` also creates a fresh worker, even for an unchanged hash. There is no local watcher or local facet reload.
-Network drops reconnect with bounded exponential backoff; subscribed references rebind and gone references reacquire.
+Network drops reconnect with bounded exponential backoff; Chord rebinds the held references.
Image attachments/results remain text placeholders; no attachment uploader, browser sheets, terminal panel or
provider-login screen is provided.
@@ -351,9 +351,20 @@ p2 runs on Chord v2 (`packages/chord`, `docs/design.md` there); the port's notes
most five times a minute per session; the next crash waits for Retry. A worker stays alive while objects
linger on it (Chat 30 s after its last viewer), on top of `P2_IDLE_MS`.
- **Principals:** owners, guests and workers as before (`src/runtime/principals.ts`); the server host acts as `SERVER`
- (workers tell its own requests from a person's), a worker as `HOST`. A method's `ctx.get` acts as its caller: code
- that must reach a worker as the server runs in `serverOf(self)().root` (a root of the server's own). Revocation is
- Chord's (`revokedBy`: a device, remote owners, a guest).
+ (workers tell its own requests from a person's), a worker as `HOST`. A method's `ctx.get` acts as its caller; a
+ `local` object's methods, routines and jobs act as the host, so a server object that must reach a worker as the
+ server is a `local` one (`SessionOperations`, `SearchIndex`, `ConversationBroker`). Code outside Chord calls (a
+ Durable tool, a route) uses `serverOf(self)().root(fn, context)` / `workerOf(self)().root(fn, context)`, which take
+ a required context: a method passes `withoutAbortSignal(ctx)` (the call chain is kept, a caller giving up does not
+ cut it off), a tool its Durable context. Revocation is Chord's (`revokedBy`: a device, remote owners, a guest).
+- **Rules this code follows** (the p2 cleanup, `packages/chord/docs/decisions.md` "App authoring rules confirmed by
+ the p2 audit"): state outside Chord is followed (`self.follow` of a synchronous observer: `Hosts.workers` follows
+ the workers component, status follows Durable's task graph), never copied by a listener; a method awaits its own
+ effects on the host that answers; long work is a job (`self.job`, persisted on the server's store where it must
+ survive a restart: session lifecycle operations, index rebuilds) and its UI watches the job's state; a session's
+ worker pushes what the server's index shows (title, `/tree` branches, subagent flags, working state) inside the
+ method that changed it, awaited, and never pulls session data (the search cursor is the server's acknowledgement
+ of what it indexed, not session data).
- **References:** `host.hold(T, params, ctx)` gives a reference at once (loading, live, failed, ended);
`ctx.get`/`host.get` one once live. UIs use `src/runtime/refs.ts`: `follow` (a value with its current one first),
`followRegistry`, `whenLive`, `statusOf`, `liveIn(ref, ...variants)` (`HeldIn`), `UI_LINGER`. A held reference's
@@ -366,6 +377,12 @@ p2 runs on Chord v2 (`packages/chord`, `docs/design.md` there); the port's notes
`TerminalServices`, `DeviceAccesses`, `ForwardsNative`, `RadiusConnections`. A reader that needs a native while it
activates also declares its owner (`self.get(Owner, {})`). Methods return `null` for nothing; their arguments are
closed objects (schemas that check stored data stay open).
+- **State outside Chord is followed:** a native's state (a store with `subscribe`, the configuration files, a
+ database, `processLogs`) or a Durable document is a state member through `self.follow` of a synchronous observer
+ (`followNative(self, subscribe, read)` from `@p2/app/v1` for a native that tells listeners; Durable's `observeDoc`), never a document a
+ routine or listener copies it into. A method that changes it through the native answers after the native told the
+ source, so the page holds the new state when the call resolves (models, envs, ports, login, mcp, logs, sharing,
+ voice mode).
- **Registries:** keyed registries (`keyedRegistry`) compose contributions into a map; the core facets serve the base
ones (`serveServerRegistries`, `serveWorkerRegistries`, `tuiCoreFacet`, `webCoreFacet`), a plugin serves its own
(`serveKeyed`). One contribution per registry per object or facet.
@@ -456,8 +473,10 @@ The stack and per-area scroll positions persist in this tab's sessionStorage; UR
Failed worker starts now publish `WorkerState.error` through `SessionStatus`, including failures before a process
could be spawned and fatal bootstrap failures after control readiness. The session view replaces its spinner with
the error, **Retry** and owner-only **Show logs**; the sidebar marks it **Worker failed**. A failed start stays
-cached so background references cannot continually respawn it. Owner-only `WorkerControl.retry` stops the old
-instance and clears the failure and notice before a new Chat hold; drafts and attachments stay intact. Creating a session
+cached so background references cannot continually respawn it. Owner-only `WorkerControl.restart` (Retry) stops the
+old instance, clears the failure and notice, starts a worker (holding `WorkerBoundary` itself when nothing else holds
+the session) and answers with the outcome: once the worker runs, or rejecting `worker_failed` with why it could not
+start, so the button shows pending and then the result; drafts and attachments stay intact. Creating a session
navigates to its view before awaiting the worker, so a failed first start also has a visible recovery screen.
Host `onError`, refused reloads and worker failures publish bounded, redacted owner notices (`LogNotices`), with
@@ -465,7 +484,7 @@ Show logs and Dismiss. Notices keep the latest error per source (at most 50 sour
messages do not become worker-failure notices. Archived-session admission refusals are not cached as crashes,
so restoring an archive still works without an extra Retry. Archiving stops the worker with
`SessionWorkerControl.suspend`, not `stop`: holders of the session's objects (a page's lingering conversation) wait
-suspended until a restore (`retry`) or their release, instead of starting a worker the archive refuses. Neither guests nor workers may read or dismiss notices, or invoke
+suspended until a restore (`resume`) or their release, instead of starting a worker the archive refuses. Neither guests nor workers may read or dismiss notices, or invoke
the worker retry control. Dev controls additionally publish their own reload/restart results.
The owner-only `logs` plugin adds **Logs** to `sidebar-foot:logs`. Its sheet selects Server, session workers,
@@ -563,8 +582,9 @@ translated phone drawer and intercept chat taps.
facet, and imports base code directly. `plugin-layout.test.ts` builds every server, worker, TUI and web facet with
Chord v2's `buildFacet` against the API.
- **Runtime registries and locals** (`src/runtime`): `Routes` (HTTP by `METHOD /path`, exact path), `WorkerInitValues`
- (spawn parameters contributed per plugin; a throw refuses the start, e.g. archived sessions), `Hosts` (worker
- states, restart for the holders), `HarnessHost`/`HarnessNative`, `Extensions`, `HarnessConfig`, `Slots`,
+ (the worker's start data, contributed per plugin; a throw refuses the start, e.g. archived sessions; a contribution
+ may wait, as the sessions plugin's does for a copy's files), `Hosts` (worker states, followed from the workers
+ component; restart for the holders), `HarnessHost`/`HarnessNative`, `Extensions`, `HarnessConfig`, `Slots`,
`useObject`/`useReadable`/`useStatus`.
- **No configuration process globals.** Diagnostics alone have native process-scoped buffers because they capture
stdout/stderr before/after object activation and survive facet reload. Host configuration is the core's: `Server` (server: `info` = data dir,
@@ -614,13 +634,28 @@ translated phone drawer and intercept chat taps.
workers while any session or subagent location uses the environment (`sessionsUsing`). Sharing: read/write links
need the remote confirmation when any of the session's conversations works on a remote environment
(`remoteEnvironments`; `Sharing.env` lists them).
+- **Session lifecycle operations** (`SessionOperations`, server, a host-lifetime `local` object of the sessions plugin;
+ its job runs persist in the server's Chord store `/server.sqlite` and run again after a crash, each run
+ idempotent): `relocate`, `clone`, `fork` and `archive` answer once their visible effect committed on the server and
+ run their long part as a job. `create`, `clone` and `fork` take a client-chosen ID (`id`, `copy`), so a page
+ navigates before the answer; a chosen ID already in the catalog answers its row (a retry). `clone` and `fork` answer
+ with the copy's row at once; the copy's worker start waits (in its start data) while its files are copied and, for
+ an ID held before its row exists, up to 10 s for the row. `relocate` validates the destination at once (a missing
+ directory rejects) and starts the move; `archive` archives the row, then closes the session's `SessionArchived`
+ contributions and suspends its worker in the job (`restore` waits for a running archive first). `Sessions.operations`
+ lists the running ones (`copying` while a copy's files are copied) and the failed ones with their error until
+ `dismissOperation`: a busy worker refusing a move or a clone, a failed copy (its row archived again, its files
+ removed). The agent's `location_move` goes through `SessionMoves.relocate`, which waits for the job and answers the
+ refusal (not recorded). Tests: `session-operations.test.ts`, `fork-clone.test.ts`, `relocate.test.ts`. The UI:
+ the web's new session and the TUI's `/new`, `/clone` and `/fork` choose the ID and open the session at once; the
+ sidebar row and the TUI selector show running operations and failed ones with Dismiss (`/dismiss-failed`), the top
+ bar "Moving…" during a move (`docs/optimistic-ui.md`).
- **Relocation (in place):** `Sessions.relocate` (web Relocate, TUI `/cd`) does not restart a running worker.
- - **Server** (`SessionWorkers.relocate`, serialized with starts, stops and pauses): fences the worker with
+ - **Server** (the relocate job; `SessionWorkers.relocate`, serialized with starts, stops and pauses): fences the worker with
`WorkerBoundary.prepare` (refused while anything runs: "Stop the current run before changing directory", a named
subagent, background work), updates the catalog, then calls `WorkerBoundary.relocate`, which runs
`Harness.relocate` and lifts the fence. The fence counts as an active call, so the worker cannot stop idle
- meanwhile. The steps run in a root of the server's own (`host.root`), not the caller's: an aborted call or closed
- tab cannot cut them off. A worker is never left fenced: a failed catalog update lifts the fence (`cancel`); a
+ meanwhile. The steps run in the job, not in the caller's call: an aborted call or closed tab cannot cut them off. A worker is never left fenced: a failed catalog update lifts the fence (`cancel`); a
worker whose fence state is unknown (prepare failed after the fence, or cancel failed) is stopped. A worker that
exits during the steps (crash, idle stop racing the fence) gets only the catalog update. While a step calls the
worker (prepare, move, cancel), its route fails on a dead link instead of queueing a start behind the relocation,
@@ -665,8 +700,6 @@ translated phone drawer and intercept chat taps.
one connection each and closing only unused machines), `harness-cwd.test.ts` (version 1 registry placement),
`envs.test.ts` (local to SSH and back in place, an own local location staying while the session moves to SSH,
real sshd).
-- **Retries:** `retrying()` (src/runtime/retry.ts) only retries `upgrade_conflict` (an old root reaching an object a
- reload migrated), in a fresh root. Roots of a worker that started behind the server are admitted by Chord.
- **Principals** (`src/runtime/principals.ts`, typed: `P2Principal`, with Chord's API typed by it in
`src/runtime/chord.ts`): `owner` (trusted loopback `access: "local"`, authenticated Radius `access: "relay"`, or a
signed owner cookie `access: "device"` with a device ID), `guest`, `worker` (stamped by the server's `session:` route
@@ -770,8 +803,10 @@ translated phone drawer and intercept chat taps.
keeps its focus and a phone's keyboard. The session is created (with the location and model chosen at that Send) whenever the owner's catalog is live, its messages move to the main
conversation's outbox, which delivers them, and the page opens the session. Messages sent meanwhile join the same
session. A creation the server refuses (its own errors: a missing directory, an unknown model) puts the messages
- back into the composer with the error; a connection failure is retried like an outbox send. A creation whose answer
- was lost with the connection can leave an empty session behind (`create` takes no request ID).
+ back into the composer with the error; a connection failure is retried like an outbox send. The page chooses the
+ session's ID at the first Send, so a creation whose answer was lost with the connection is tried again with the same
+ ID and answers the same session; the new session's worker starts beside the creation (the page holds its main
+ conversation while `create` runs; the worker's start waits for the row).
- **Chat frame** (`Main` in `plugins/chat/web.tsx`): the chat's `.chat` area and its dock outlive the screens shown in
it (the new-session screen, a conversation, the next one). There is one composer, in the dock: a screen publishes
what the dock shows (`useChatScreen`: a bar above, the composer's settings, queued rows, the footer, or a bar in the
@@ -800,26 +835,32 @@ translated phone drawer and intercept chat taps.
conversation) has one height in every state. Rows far from the viewport are not in the DOM: browser find-in-page and
tests see only rendered rows (`.rows[data-rows]` counts them); `jumpToEntry` reveals an entry through the list
(`revealInTranscripts`).
-- **References** (Chord entry 8): an awaited `ctx.get`/`host.get` is ready with its state; UI runtimes hold
- synchronously (`host.hold` in `useObject` and the TUI's `watch`) and read state through `useReadable` (undefined
- until hydrated). Multi-variant references narrow on `variant` before using a member that not every variant has:
- inline, `inVariant(ref, ...variants)` (the ref or `undefined`; a loading ref has no variant yet), `useObject(type,
- input, { variant })`, or `expectVariant` where a mismatch is a bug (throws `not_in_variant`); all in
- `src/runtime/chord.ts`. A TUI `watch` binds after `ready`, so its callback narrows on `ref.variant`. Tests use
- `holdIn(host, type, input, variant)` (held, ready, `expectVariant`). Method-only references take `{ state: false }`.
+- **References**: an awaited `ctx.get`/`host.get` is live with its state; UI runtimes hold synchronously
+ (`host.hold` in `useObject` and `TuiScope.watch`) and read state through `useReadable` (undefined until hydrated).
+ Chord rebinds a held reference by itself (a dropped link, a restarted worker, a reload); the UI never holds one
+ again. Multi-variant references narrow with `liveIn(ref, ...variants)` or `useObject(type, params, { variant })`;
+ a TUI `watch` binds once the reference is live, so its callback narrows on `ref.variant`. Tests use
+ `holdIn(host, type, params, variant)`.
+- **Optimistic UI** (`docs/optimistic-ui.md`): actions show their effect at once (`useOptimisticCall` on the web,
+ `scope.optimistic(read, reduce?)` in the terminal, Chord's `TuiScope.optimistic`), long work shows a pending state,
+ refusals roll back with the error.
- **Lingering views** (`UI_LINGER`, `src/runtime/refs.ts`): the web's `useObject` and the TUI's `TuiScope` release a
Chat reference 2 minutes after its view goes (at most 4), so switching back to a conversation binds at once; a
- lingering reference counts as a viewer for worker liveness. Transcripts live per host and conversation (`transcriptOf`, at most 4 not
+ lingering reference keeps the worker up (a reference over its link). Transcripts live per host and conversation (`transcriptOf`, at most 4 not
viewed), and a view taken again merges its snapshot into them.
-- **Worker liveness**: viewers (live Chat objects and held workspace backends), active calls, Durable tasks; the host manager stops an idle worker
- after `P2_IDLE_MS`. Browsers re-`get` a reference that went `gone` (`useObject`).
-- **Conversation views** are mirrored with `cell.replace`: a new Durable revision shares unchanged containers with
- the last, so subscribers get a diff. `Chat` exposes `Conversation.view` directly (a held reference's state member).
- Durable watches become listener sources without memoizing: a reload keeps the subscription (same resource, same
- `select` source text), so a watch is never started twice.
+- **Worker liveness**: Chord's `host.activity` (references over the worker's links, running jobs such as a user
+ shell command, lingering objects such as a `Conversation` for 30 s) plus p2's counts (active
+ calls under the relocation fence, Durable tasks); the host manager stops an idle worker after `P2_IDLE_MS`. Browsers re-`get` a reference that went `gone` (`useObject`).
+- **Durable state is followed, never copied** (`self.follow` over Durable's synchronous observers): a
+ conversation's view (`observeConversation`, shared by every viewer through `Conversation.view`), chat mode's switch
+ and name (`observeDoc(ChatModeDoc)`), a conversation's artifact list (`observeDoc(ArtifactDoc)`), the task graph
+ (status). A document nobody wrote yet is absent and cannot be followed, so chat mode and artifacts create it empty on
+ first follow. A method that writes such a document returns after Durable's commit, by when the follow has the new
+ value (`chat-mode.test.ts`). The same holds for p2's own natives: `PromptResources` follows the prompt loads.
- **Sharing** (`plugins/sharing`): `Shares` (server local, `shares.sqlite`: links, guest identities with a
- revision), `Presence` (connected guests, in memory), `GuestPresence` (held by a guest page's web facet; its
- resource enters/leaves `Presence`), owner-only `Sharing` (links, connected guests, link/reset/remove). Guest
+ revision; the database tells listeners of each committed change and `Shares` follows it), `Presence` (connected
+ guests: derived from `host.references(GuestPresence)`, design §19), `GuestPresence` (held by a guest page's web
+ facet; a link drop is a leaving), owner-only `Sharing` (links, connected guests, link/reset/remove). Guest
principals carry their grant revision; removal, link reset, archive and restore bump it and call
`invalidatePrincipal`, so the guest's objects go `gone` and its socket closes. The server consults the
`Authenticators` registry (guest cookie) and `SessionAccess` (guests reach only their session at the current
@@ -961,7 +1002,7 @@ translated phone drawer and intercept chat taps.
committed it, and the cursor sent stays put until the last page of a range, so an interrupted scan repeats the
range (idempotent by entry ID). It runs at start and after every commit adding entries (Durable
`observeCommits`), and retries after an error (server unreachable, reload) from a fresh cursor. The speaker and
- peer-sender blocks are not indexed. Owner-only `Search.search({ query, archived })`: sessions whose title or cwd
+ peer-sender blocks are not indexed. Owner-only `Search.rebuildIndex({ session })` (Pi Mobile's) starts a persisted job on `SearchIndex` (stop the worker, reset its entries, scan from a fresh worker; an archived session's starts without resuming its work and stops again) and answers once it started; `Search.rebuilds` shows it running and why it failed (`rebuild-index.test.ts`). Owner-only `Search.search({ query, archived })`: sessions whose title or cwd
contains the query (sidebar order) and up to 41 message hits (literal terms, all required) in the archive scope.
Search hits include conversation IDs, and a hit opens that conversation (a subagent's too); default titles derive from the first root user message, explicit titles
survive reindexing, and `updatedAt` advances monotonically with indexed activity. The sidebar search box (owner) filters sessions and
@@ -994,8 +1035,9 @@ translated phone drawer and intercept chat taps.
for every request (pages, WebSocket, files); the loopback owner cookie wins over a guest cookie (the owner's
browser stays owner). Checked only on loopback. `loopback-join.test.ts` (HTTP and Chromium with fresh profiles).
- **Provider login** (`plugins/login`): `LoginService` (server local) runs its own `Logins` (provider-owned
- api-key and OAuth flows); its events arrive through a listener and become the `login` state (status, the pending
- prompt, notices; never credentials or answers). `ProviderLogin` admits every owner (local, device-associated,
+ api-key and OAuth flows), which keeps the flow's `login` state (status, the pending prompt, notices; never
+ credentials or answers); the service follows it, so `start`, `answer`, `dismiss` and `cancel` (which waits for the
+ flow to end) answer with their change shown. `ProviderLogin` admits every owner (local, device-associated,
or Radius), never guests, and adds the provider list. Web: Providers in the sidebar footer for every
owner, regardless of browser hostname. Remote flows automatically choose device-code when the provider offers
it; account selectors remain interactive. Browser-callback flows keep the provider's registered callback URL:
@@ -1005,7 +1047,8 @@ translated phone drawer and intercept chat taps.
`openBrowserOnConfirm: false` (a provider asking clients not to offer opening its page) shows the verification
address as text instead of the Open link (web), and the TUI offers no "open in browser" for it. No pi-ai changes or custom
callback URL. API keys and pasted codes pass through the tunnel/relay; use a trusted transport. Once no browser
- holds the sheet, an unfinished login is cancelled. Tests use fake providers (login.test.ts and remote-login.test.ts).
+ holds the sheet (no reference to its owner's `ProviderLogin` left, `host.references`), its unfinished login is
+ cancelled. Tests use fake providers (login.test.ts and remote-login.test.ts).
- **Web slots**: `SlotList({ prefix })` renders every `prefix:` slot (e.g. `sidebar-foot:providers`); `Sheet` is
the shared modal with scroll memory (Escape and the scrim close the whole modal stack). Shell-level
`workspace-panel` docks beside `main`; `workspace-terminal` is an optional terminals-plugin surface.
@@ -1015,9 +1058,8 @@ translated phone drawer and intercept chat taps.
after source deletion or plugin reload. `test/p2/send-file.test.ts` and the files integration tests cover it.
The artifacts plugin supplies the shared viewer.
- **Artifacts and viewer** (`plugins/artifacts`): owns its native artifact tool and immutable version
- storage (2 MiB UTF-8). `ArtifactList` reads the conversation's latest-only `ArtifactDoc`, independently of
- transcript paging, compaction and reset; it subscribes before catching up and validates the conversation during
- activation. `/artifact?session=&name=&version=` rechecks authorization for owner/guest, rejects cross-site reads,
+ storage (2 MiB UTF-8). `ArtifactList` follows the conversation's latest-only `ArtifactDoc`, independently of
+ transcript paging, compaction and reset, and validates the conversation during activation. `/artifact?session=&name=&version=` rechecks authorization for owner/guest, rejects cross-site reads,
and serves the preview with a CSP sandbox (no same-origin privilege, forms, child frames or objects). Only
previews receive the diagnostics bridge; `download=1` returns the original source. HEAD describes the same
representation as GET. The chat-content slot supplies the shared transcript card contexts and explicit-click
@@ -1147,8 +1189,9 @@ translated phone drawer and intercept chat taps.
exactly its locations (leaving one out ends that lease), a newer generation takes the session over and refuses an
older one (decided inside the leases' change), calls go through the setting incarnation's lease, and the view's end
(its worker's link went, after a 30 s linger, so a worker object taking it again keeps its processes) releases its
- generation's leases; a reload keeps them and the last listed tools (in-memory
- documents of the manager). Unshared instances close with their last lease, shared ones stay while configured. A lost
+ generation's leases; a reload keeps them (an in-memory document of the manager), and the restarted natives start
+ from the manager's memento of the last valid servers and the last listed tools. The manager follows `mcp.json`, the
+ instances and the sign-ins (`self.follow`), so restart, sign-in, sign-out and cancel answer with their change shown. Unshared instances close with their last lease, shared ones stay while configured. A lost
connection reconnects with backoff (`RECONNECT`: 1 s doubling to 60 s, reset after a minute up), tools still listed;
`notifications/tools/list_changed` refreshes them; call errors never carry a server's stderr (the owner's `error`
does). The worker object (`p2.mcp-worker`) sends `setLocations` with every location its conversations work at (the
@@ -1206,7 +1249,7 @@ translated phone drawer and intercept chat taps.
browser through the worker's `SubagentControl({ session }).archive({ conversation, archived })` (owner and read/write
guests). Archiving aborts the conversation first (`setArchived`, shared by both); `send` clears the flag; spawning an
archived name says so. `status` omits archived agents unless named and adds "N archived (status with a name shows
- one)." `Child.archived` reaches the sidebar (`ChildStore`): archived
+ one)." `Child.archived` reaches the sidebar (`ChildStore`), pushed by the worker before `SubagentControl.archive` answers (the tool's changes after their commit): archived
children sit behind an **N archived** row per session, open while viewing one. The subagent view now has mobile's
bar (Tools, Archive/Restore). The TUI lists no subagents, so archived ones need nothing there.
`subagents.test.ts` covers tool archive, status, browser restore/archive-with-abort, send restoring and guest
diff --git a/apps/p2/docs/p2-tui-port.md b/apps/p2/docs/p2-tui-port.md
index 4f5479f25..c24f14479 100644
--- a/apps/p2/docs/p2-tui-port.md
+++ b/apps/p2/docs/p2-tui-port.md
@@ -1159,7 +1159,7 @@ Requests are debounced (~80 ms), aborted when superseded, and inserted as `@path
| Shell, navigation, drawer | `P2/plugins/web-shell/web.tsx`, `P2/src/web/AppLayout.tsx`, `P2/src/web/navigation.ts` | Screens + overlays; Ctrl+R and `/resume` instead of a sidebar |
| Sessions grouped by env:cwd, pinned, busy, children | `P2/src/web/Drawer.tsx` | Session selector (1.9, 1.17) |
| New session (env + directory browser, model, first prompt) | `P2/src/web/new-session.tsx`, `P2/plugins/envs/DirectoryPicker.tsx` | `/new` overlay (`Envs.envs`, `Envs.listDirectory`) |
-| Session options (rename, pin, archive/restore, relocate) | `P2/plugins/sessions/SessionOptions.tsx` | `/name`, `sessions.pin`, `sessions.archive`, `sessions.relocate` |
+| Session options (rename, pin, archive/restore, relocate) | `P2/plugins/sessions/web.tsx` (Session sheet) | `/name`, `sessions.pin`, `sessions.archive`, `sessions.relocate` |
| Search, archived | `P2/plugins/search`, `P2/src/web/Drawer.tsx` | Session selector (1.17) |
| Transcript, tool cards | `P2/src/web/Chat.tsx`, `P2/src/web/Markdown.tsx`, `P2/src/web/Code.tsx` | 1.3, 1.4 |
| Composer (steer/follow-up, stop, queue edit, attachments, drag/drop) | `P2/plugins/chat/web.tsx:83-200`, `P2/src/web/composer-state.tsx` | Editor, pending area, attachments via **L:pasteImage/attach** shown as chips in a `composer:attachments` widget |
@@ -1571,8 +1571,8 @@ Tests: `P2/test/p2/shell.test.ts` (a bare session host with real Durable and a r
- One command at a time; Escape cancels.
- **Contract.**
- `Chat` is version 4 (versions 2 and 3 are accepted as they are).
- - Participants get `shell({command, includeInContext, requestId})` → `{shellId}` and `abortShell({shellId})` →
- `{aborted}`.
+ - Participants get `shell({command, includeInContext, requestId})` → `{shellId}` (the `requestId`, so a TUI shows
+ the command's block under its final ID before the state arrives) and `abortShell({shellId})` → `{aborted}`.
- Both variants get the state `liveShell` (a state member cannot share the method's name):
`{id, command, author, includeInContext, output, truncated, running, exitCode?, cancelled, error?} | null`.
- **Who may:** anyone who may send, so full guests too, as tools run commands for them. Viewers watch.
@@ -1580,21 +1580,24 @@ Tests: `P2/test/p2/shell.test.ts` (a bare session host with real Durable and a r
- **Cancelling:** the command's author or the owner.
- **One at a time per conversation;** a second command is refused with pi's text (`SHELL_BUSY`).
- **Retries:** a call with the same request ID runs once.
-- **Worker** (`P2/plugins/chat/worker.ts`; the shared `Conversation` object runs it, `runShell`/`abortShell`):
+- **Worker** (`P2/plugins/chat/shell.ts`; the worker's `ShellCommands` object, which `Chat` holds):
+ - A command is a Chord job (`p2.shell-commands.run`): `Chat.shell` returns once the run is started (committed with
+ the request ID it dedupes, among the last 100 starts per author), so `liveShell` shows it when the call answers.
- The command runs on the conversation's environment, `Harness.environments.at(agent.cwd)`: local or remote, and
a subagent's own cwd.
- It uses the bash tool's settings (`inheritEnv`, spill and an output window at Durable's 2000 lines / 50 KiB,
sampled every 200 ms at 100 KiB/s).
- - The kept tail is published as `liveShell`.
- - It runs as one active call (`Liveness.call`), so the worker stays up, and a move or clone waits for it. It runs
- under its own root context (the method returns at once). Closing the object cancels it.
+ - The kept tail is the run's state document, which `liveShell` shows.
+ - A running job keeps the worker up (`host.activity.jobs`) and counts as one active call (`Liveness`, from before
+ its start until its run ends), so a move or a clone waits for it. `abortShell` aborts the run and answers once
+ `liveShell` shows it cancelled. A reload of the chat plugin or the worker's end aborts it.
- When it ends, a Durable write submission places a `p2.shell` entry (`P2/src/runtime/shell.ts`, `ShellData`:
author, command, output tail, `exitCode`, `cancelled`, `truncated`, `fullOutputPath`, `includeInContext`, `error`).
- **`!`:** the entry also carries a user message (the sender block plus pi's `bashExecutionToText`,
`shellContextText`). The agent reads it on its next run, and no run starts now.
- **`!!`:** the entry is display-only.
- - **Busy agent:** while the agent runs, the entry is placed at the run's next boundary, like any write.
- `liveShell` clears once it is placed.
+ - **Busy agent:** while the agent runs, the entry is placed at the run's next boundary, like any write. The run
+ waits for the placement, so `liveShell` clears once the entry shows.
- The data has no `text` field, so no reader takes it for a person's message.
- **Deviation:** a command is not refused during a compaction; the write's placement keeps the order.
- **TUI** (`P2/plugins/chat/tui.ts`):
diff --git a/apps/p2/docs/plugin-hooks.md b/apps/p2/docs/plugin-hooks.md
index 07b1bd006..6ed7af76f 100644
--- a/apps/p2/docs/plugin-hooks.md
+++ b/apps/p2/docs/plugin-hooks.md
@@ -189,7 +189,7 @@ that must survive reload reads live resources through getters (`plugins/codemode
| `HarnessEnvironments` | registry, key `"remote"` only | opens a remote machine (`at(cwd)` environments, one connection) per environment name | `src/runtime/worker.ts` | envs |
| `ConversationLocations` | local, one provider | conversations following the session, own locations (`place`, `own`) | `src/runtime/worker.ts` | subagents (`plugins/subagents/worker.ts`) |
| `Harness` | local, lifetime "host" | `harness` (native Durable Harness), `environments` (`session`, `locate(id)`, `at(location)`) | `src/runtime/worker.ts` | most worker plugins |
-| `WorkerProcess` | local, lifetime "host" | `init` (dataDir, generation, `values` from `WorkerInitValues`), `liveness` (`add("viewers" \| "activeCalls")`, `stopping`) | `src/runtime/worker.ts:165` | chat and workspace (viewers), codemode (`stopping`) |
+| `WorkerProcess` | local, lifetime "host" | `init` (dataDir, generation, `values` from `WorkerInitValues`), `liveness` (`add("viewers" \| "activeCalls")`, `stopping`) | `src/runtime/worker.ts` | workspace (viewers), participants' calls (`activeCalls`, the relocation fence), codemode (`stopping`) |
| `Conversation` | local (chat) | `submit` (input with own `entry` kind), `write` (display-only entry), `stop`, `history`, `compact`, `reset`, `runShell`… | `plugins/chat/contract.ts:140-189` | chat-mode, conversations |
```ts
diff --git a/apps/p2/docs/tree.md b/apps/p2/docs/tree.md
index 16a3b42af..080df0bc5 100644
--- a/apps/p2/docs/tree.md
+++ b/apps/p2/docs/tree.md
@@ -42,8 +42,8 @@ version 2; absent: `{ 1, [{ 1, null }] }`):
(`main`; model and thinking level follow it), the web home route, the drawer, the chat's back links, TUI checks,
terminals, reading unread answers, `Sessions.fork`, and peer messages (the conversations broker).
- Clients learn it through the session row (`Sessions` version 4, catalog schema 4; version 5: null labels); the
- worker reports it after a switch and at every start (`treeSelf`). Clients built before still bind: the providers
- accept `Sessions` 3 and 4 and `SessionTree` 1, mapping rows and the tree back (no `main`/`branches` for 3, a null
+ worker pushes it inside the switch, before answering, and at every start (`treeSelf`). Clients built before still
+ bind: the providers accept `Sessions` 3, 4 and 5 and `SessionTree` 1, mapping rows and the tree back (no `main`/`branches` for 3, a null
label as the session title for 4, no labels for `SessionTree` 1). A web page or TUI viewing the previous main conversation follows a switch.
- Earlier branches are read-only (`ReadOnlyConversations`): only the main conversation takes input (send, dequeue,
configure); Stop stays allowed. A branch left while it works keeps running in the background: its run and queued
@@ -122,7 +122,8 @@ node:
the current one marked in place), behind the session row's expand control, which also shows its subagents
(collapsed by default, expanded while a subagent is shown); choosing one switches the session to its end
(`SessionTree.select`). The worker
- reports `p2.main` (`treeSelf`: `main` and the branches with their labels) at start and whenever a commit changes it.
+ pushes `p2.main` (`treeSelf`: `main` and the branches with their labels) at start and inside each `SessionTree`
+ method that changes it, before the method answers (nothing else changes `p2.main`).
- Search indexes each branch's own entries (after its fork point).
## Later
diff --git a/apps/p2/plugins/artifacts/worker.ts b/apps/p2/plugins/artifacts/worker.ts
index 1a6442c3b..6c2cefb5f 100644
--- a/apps/p2/plugins/artifacts/worker.ts
+++ b/apps/p2/plugins/artifacts/worker.ts
@@ -1,27 +1,10 @@
import type { ConversationId } from "@earendil-works/pi-durable";
-import { arr, obj } from "@earendil-works/pi-schema";
-import {
- type Context,
- canView,
- contribute,
- defineFacet,
- defineTypedState,
- deriveTyped,
- forbidden,
- signalOf,
-} from "@p2/app/v1";
-import { agentConversation, CommitSignal, Extensions, harnessOf, workerOf } from "@p2/app/v1/worker";
-import { ArtifactContent, ArtifactInfoSchema, ArtifactList, MAX_ARTIFACT_PREVIEW } from "./contract.ts";
+import { canView, contribute, defineFacet, deriveTyped, forbidden } from "@p2/app/v1";
+import { agentConversation, Extensions, harnessOf, workerOf } from "@p2/app/v1/worker";
+import { ArtifactContent, ArtifactList, MAX_ARTIFACT_PREVIEW } from "./contract.ts";
import { Artifacts } from "./store.ts";
import { ArtifactDoc, createArtifactExtension } from "./tool.ts";
-/** The artifacts of one conversation (in memory, from its document). */
-const Listed = defineTypedState("p2.artifact-list", {
- schema: obj({ artifacts: arr(ArtifactInfoSchema) }),
- version: 1,
- initial: () => ({ artifacts: [] }),
-});
-
export default defineFacet((host) => {
contribute(host, "p2.artifact-tools", (self) => {
const worker = workerOf(self);
@@ -63,40 +46,18 @@ export default defineFacet((host) => {
if (!canView(principal, session)) throw forbidden("viewers only");
const harness = harnessOf(self);
const id = conversation as ConversationId;
- const listed = self.state(Listed);
- /** Where the viewed agent's list is (the main agent's on conversation 1); set when the object opens. */
- let holder = id;
- const show = async (ctx: Context) => {
- const doc = await harness().durable.snapshot(ArtifactDoc, holder, ctx);
- await listed.change((draft) => {
- // A draft's arrays are mutable; the document's are the same JSON.
- draft.artifacts = [...(doc?.artifacts ?? [])] as typeof draft.artifacts;
+ // The viewed agent's list (the main agent's on conversation 1), followed.
+ const listed = self.follow(async (observer, ctx) => {
+ const durable = harness().durable;
+ if (!(await durable.conversation(id, ctx))) throw new Error(`Unknown conversation ${conversation}`);
+ const holder = await agentConversation(durable, id, ctx);
+ const observed = await durable.observeDoc(ArtifactDoc, holder, observer, ctx);
+ if (observed !== undefined) return observed;
+ // No artifact saved yet: the document is absent, and an absent address cannot be followed. Create it empty.
+ await durable.commit(async (tx) => {
+ await tx.doc(ArtifactDoc, holder);
}, ctx);
- };
- const commits = self.open(
- async (ctx) => {
- const durable = harness().durable;
- if (!(await durable.conversation(id, ctx))) throw new Error(`Unknown conversation ${conversation}`);
- holder = await agentConversation(durable, id, ctx);
- await show(ctx);
- return new CommitSignal(durable, (publication) =>
- publication.changes.some(
- (change) =>
- change.type === "document" &&
- change.record.kind === ArtifactDoc.definition.kind &&
- change.conversationId === holder,
- ),
- );
- },
- (signal) => signal.close(),
- );
- self.onLive(async (ctx) => {
- const signal = signalOf(ctx);
- while (!signal.aborted) {
- const seen = commits.value.version;
- await show(ctx);
- await commits.value.next(seen, signal).catch(() => {});
- }
+ return durable.observeDoc(ArtifactDoc, holder, observer, ctx);
});
return { variant: "reader", members: { artifacts: deriveTyped(self, [listed], ([value]) => value.artifacts) } };
});
diff --git a/apps/p2/plugins/chat-mode/contract.ts b/apps/p2/plugins/chat-mode/contract.ts
index 4038037db..a0a74315c 100644
--- a/apps/p2/plugins/chat-mode/contract.ts
+++ b/apps/p2/plugins/chat-mode/contract.ts
@@ -99,7 +99,7 @@ export function spokenMention(text: string, name = DEFAULT_AGENT_NAME): string {
return spokenName(name).test(text) ? `${mentionToken(name)} ${text.trim()}` : text;
}
-/** One conversation's switch and agent name (worker-local), mirrored from its Durable document. */
+/** One conversation's switch and agent name (worker-local), followed from its Durable document. */
export const ChatModeSetting = local("p2.chat-mode-setting", {
/** 2: the agent's name. 3: `waiting`. */
version: 3,
diff --git a/apps/p2/plugins/chat-mode/tui.ts b/apps/p2/plugins/chat-mode/tui.ts
index e182b227c..c1cef2cf8 100644
--- a/apps/p2/plugins/chat-mode/tui.ts
+++ b/apps/p2/plugins/chat-mode/tui.ts
@@ -2,12 +2,13 @@
// and note (the shared `TranscriptEntries` display), a composer chip shows the mode (the owner toggles it with
// `/chat-mode`), and while it is on `/reply` picks the message the next send answers and `@pi` completes first.
-import type { Held } from "@p2/app/v1";
+import type { Held, HeldIn } from "@p2/app/v1";
import { BACKGROUND_CONTEXT, contribute, defineFacet, liveIn, statusOf } from "@p2/app/v1";
import type { TuiContext } from "@p2/app/v1/tui";
import {
chatSurface,
ListOverlay,
+ type OptimisticAdd,
type TuiCommand,
TuiCommands,
type TuiRuntime,
@@ -26,8 +27,12 @@ export class ChatModeChip implements TuiSurface {
readonly runtime: TuiRuntime;
private scope: TuiScope | undefined;
private identity: string | undefined;
- private mode: () => Held | undefined = () => undefined;
- enabled = false;
+ private mode: Held | undefined;
+ /** The switch the owner flipped while its call is in flight (`/chat-mode`). */
+ /** The switch as shown (the owner's flip while its call is in flight) and how to flip it. */
+ private shownEnabled: () => boolean = () => false;
+ private switching: OptimisticAdd | undefined;
+ private enabledState = false;
/** The agent's name in this conversation (what people mention). */
name = DEFAULT_AGENT_NAME;
private waiting = 0;
@@ -40,16 +45,19 @@ export class ChatModeChip implements TuiSurface {
this.identity = runtime.identity;
this.scope?.dispose();
this.scope = undefined;
- this.mode = () => undefined;
- this.enabled = false;
+ this.mode = undefined;
+ this.switching = undefined;
+ this.shownEnabled = () => false;
+ this.enabledState = false;
this.waiting = 0;
this.name = DEFAULT_AGENT_NAME;
const session = runtime.session;
if (!session) return;
this.scope = new TuiScope(runtime);
+ [this.shownEnabled, this.switching] = this.scope.optimistic(() => this.enabledState);
this.mode = this.scope.watch(ChatMode, { session, conversation: runtime.conversation }, (ref, follow) => {
follow(ref.enabled, (enabled) => {
- this.enabled = enabled ?? false;
+ this.enabledState = enabled ?? false;
});
follow(ref.waiting, (waiting) => {
this.waiting = waiting ?? 0;
@@ -63,14 +71,30 @@ export class ChatModeChip implements TuiSurface {
this.stop = () => runtime.listeners.delete(changed);
changed();
}
+ /** Whether chat mode is on, as shown: the owner's switch while its call is in flight, else the state. */
+ get enabled(): boolean {
+ return this.shownEnabled();
+ }
/** The owner's live reference (to switch the mode). */
- owner(): Held | undefined {
- const ref = this.mode();
- return liveIn(ref, "owner");
+ owner(): HeldIn | undefined {
+ return liveIn(this.mode, "owner");
+ }
+ /** Flips the mode: the chip shows it at once; a refused call flips it back and says why. */
+ toggle(): void {
+ const owner = this.owner();
+ if (!owner || !this.switching) {
+ this.runtime.notify("Chat mode is not available here", "warning");
+ return;
+ }
+ const enabled = !this.enabled;
+ this.runtime.perform(
+ this.switching(enabled, owner.set({ enabled }, BACKGROUND_CONTEXT)).then(() =>
+ this.runtime.notify(enabled ? `Chat mode on: ${hint(this.name)}` : "Chat mode off"),
+ ),
+ );
}
private live(): boolean {
- const ref = this.mode();
- return ref !== undefined && statusOf(ref) === "live";
+ return this.mode !== undefined && statusOf(this.mode) === "live";
}
/** Replies belong to chat mode (the web's `setCanReply`). */
private syncReplies(): void {
@@ -134,17 +158,10 @@ export const CHAT_MODE_COMMANDS: Record = {
category: "Chat",
when: "chat && owner && !overlay",
slash: { name: "chat-mode" },
- run: async (ctx) => {
+ run: (ctx) => {
const chip = chipOf(ctx);
- const owner = chip?.owner();
- if (!chip || owner?.variant !== "owner") {
- ctx.notify("Chat mode is not available here", "warning");
- return;
- }
- // The chip follows the state as it arrives; the notice says what was asked for.
- const enabled = !chip.enabled;
- await owner.set({ enabled }, BACKGROUND_CONTEXT);
- ctx.notify(enabled ? `Chat mode on: ${hint(chip.name)}` : "Chat mode off");
+ if (chip) chip.toggle();
+ else ctx.notify("Chat mode is not available here", "warning");
},
},
"p2.chatMode.reply": {
diff --git a/apps/p2/plugins/chat-mode/web.tsx b/apps/p2/plugins/chat-mode/web.tsx
index c4b1946ec..b782d5f42 100644
--- a/apps/p2/plugins/chat-mode/web.tsx
+++ b/apps/p2/plugins/chat-mode/web.tsx
@@ -2,14 +2,22 @@
// second chip shows how the agent is mentioned (`@pi`); the owner taps it to rename the agent for this conversation
// (what people type after `@`, and what voice mode listens for).
-import { BACKGROUND_CONTEXT, defineFacet } from "@p2/app/v1";
-import { Sheet, Slots, useComposerState, useObject, useReadable, useRoute, useStatus } from "@p2/app/v1/web";
+import { BACKGROUND_CONTEXT, contribute, defineFacet } from "@p2/app/v1";
+import {
+ Sheet,
+ Slots,
+ useComposerState,
+ useObject,
+ useOptimisticCall,
+ useReadable,
+ useRoute,
+ useStatus,
+} from "@p2/app/v1/web";
import { useEffect, useState } from "react";
import { TranscriptEntries } from "../chat/contract.ts";
import { ChatMode, DEFAULT_AGENT_NAME, mentionToken } from "./contract.ts";
import { CHAT_ENTRY_DISPLAYS } from "./display.ts";
import "./style.css";
-import { contribute } from "@p2/app/v1";
const hint = (name: string) =>
`Chat mode: messages go to the people in this session, not to ${name}. Mention ${mentionToken(name)} (or say "${name}") to ask ${name}; it then reads the chat since its last answer.`;
@@ -17,25 +25,21 @@ const hint = (name: string) =>
/** One word, as typed after `@` and spoken: letters, digits, `_` and `-`, starting with a letter (AgentNameSchema). */
const VALID_NAME = /^[A-Za-z][A-Za-z0-9_-]{0,31}$/;
-/** The owner's sheet for the agent's name in this conversation. */
-function NameSheet({
- name,
- onSave,
- onClose,
-}: {
- name: string;
- onSave: (name: string) => Promise;
- onClose: () => void;
-}) {
+/** What the chip shows: the mode and the agent's name; a guess changes either. */
+type Shown = { readonly enabled: boolean; readonly name: string };
+
+/**
+ * The owner's sheet for the agent's name in this conversation. Saving closes it at once: the chip shows the new name
+ * while the rename is in flight, and a refused rename puts the old one back with the error in the toast.
+ */
+function NameSheet({ name, onSave, onClose }: { name: string; onSave: (name: string) => void; onClose: () => void }) {
const [draft, setDraft] = useState(name);
- const [error, setError] = useState();
const valid = VALID_NAME.test(draft.trim());
const save = () => {
const next = draft.trim();
if (!VALID_NAME.test(next)) return;
- void onSave(next)
- .then(onClose)
- .catch((failure: unknown) => setError(failure instanceof Error ? failure.message : String(failure)));
+ onSave(next);
+ onClose();
};
return (
@@ -63,7 +67,6 @@ function NameSheet({
People mention it as {mentionToken(valid ? draft.trim() : name)}, and in voice mode its name anywhere in
what they say counts. One word: letters, digits, - and _.
- {error && {error}
}
Save
@@ -85,13 +88,17 @@ function ChatModeChip() {
const { session } = route;
const mode = useObject(ChatMode, session ? { session, conversation: route.conversation ?? 1 } : undefined);
const live = useStatus(mode) === "live";
- const enabled = useReadable(mode?.enabled) ?? false;
const waiting = useReadable(mode?.waiting) ?? 0;
- const name = useReadable(mode?.name) ?? DEFAULT_AGENT_NAME;
+ const { setCanReply, setReplyTo, setMentionName, fail } = useComposerState();
+ // The switch and the name flip at the tap; the state that arrives with the answer replaces the guess.
+ const [{ enabled, name }, guess] = useOptimisticCall>(
+ { enabled: useReadable(mode?.enabled) ?? false, name: useReadable(mode?.name) ?? DEFAULT_AGENT_NAME },
+ (shown, change) => ({ ...shown, ...change }),
+ fail,
+ );
const [renaming, setRenaming] = useState(false);
const label = enabled && waiting ? `Chat · ${waiting}` : "Chat";
// Replies belong to chat mode: the transcript offers them while it is on, and the composer offers the mention.
- const { setCanReply, setReplyTo, setMentionName } = useComposerState();
const replies = live && enabled;
useEffect(() => {
setCanReply(replies);
@@ -122,9 +129,7 @@ function ChatModeChip() {
className={`chip toggle chat-mode ${enabled ? "on" : ""}`}
aria-pressed={enabled}
title={title}
- onClick={() =>
- void mode.set({ enabled: !enabled }, BACKGROUND_CONTEXT).catch((error: unknown) => console.error(error))
- }
+ onClick={() => guess({ enabled: !enabled }, () => mode.set({ enabled: !enabled }, BACKGROUND_CONTEXT))}
>
{label}
@@ -142,7 +147,7 @@ function ChatModeChip() {
{renaming && (
mode.rename({ name: next }, BACKGROUND_CONTEXT)}
+ onSave={(next) => guess({ name: next }, () => mode.rename({ name: next }, BACKGROUND_CONTEXT))}
onClose={() => setRenaming(false)}
/>
)}
diff --git a/apps/p2/plugins/chat-mode/worker.ts b/apps/p2/plugins/chat-mode/worker.ts
index 8a999710d..9d15f2c94 100644
--- a/apps/p2/plugins/chat-mode/worker.ts
+++ b/apps/p2/plugins/chat-mode/worker.ts
@@ -9,8 +9,9 @@ import {
type EntryId,
type EntryRecord,
InboxDoc,
+ type InboxState,
} from "@earendil-works/pi-durable";
-import { bool, num, obj } from "@earendil-works/pi-schema";
+import { num, obj } from "@earendil-works/pi-schema";
import {
type Context,
type Ctx,
@@ -18,17 +19,15 @@ import {
contribute,
defineFacet,
defineState,
+ deriveTyped,
forbidden,
invalid,
isOwner,
- signalOf,
} from "@p2/app/v1";
-import { durableContext } from "@p2/app/v1/node";
-import { agentConversation, CommitSignal, harnessOf } from "@p2/app/v1/worker";
+import { agentConversation, harnessOf } from "@p2/app/v1/worker";
import { Conversation, SendRouters } from "../chat/contract.ts";
import { Attachments, MAX_ATTACHMENTS } from "../files/contract.ts";
import {
- AgentNameSchema,
CHAT_ENTRY,
type ChatMessage,
ChatMode,
@@ -57,16 +56,11 @@ export const ChatModeDoc = defineDoc<{ enabled: boolean; name: string }>({
/** A conversation's newest entries, newest first, until `unmentioned` has what it needs (at most `limit`). */
async function recentEntries(durable: DurableHarness, id: ConversationId, ctx: Context, limit = 2000) {
- const conversation = await durable.conversation(id, durableContext(ctx));
+ const conversation = await durable.conversation(id, ctx);
const entries: EntryRecord[] = [];
let before = Number.MAX_SAFE_INTEGER;
while (conversation && entries.length < limit && !readEnough(entries)) {
- const page = await conversation.entries(
- { maxEntryId: (before - 1) as EntryId },
- 256,
- undefined,
- durableContext(ctx),
- );
+ const page = await conversation.entries({ maxEntryId: (before - 1) as EntryId }, 256, undefined, ctx);
for (const entry of page.items) {
entries.push(entry);
before = entry.id;
@@ -81,20 +75,20 @@ async function recentEntries(durable: DurableHarness, id: ConversationId, ctx: C
* conversation's own entries, so an ID from another conversation is not found.
*/
export async function replyTarget(durable: DurableHarness, id: ConversationId, entryId: number, ctx: Context) {
- const conversation = await durable.conversation(id, durableContext(ctx));
+ const conversation = await durable.conversation(id, ctx);
const only = { minEntryId: entryId as EntryId, maxEntryId: entryId as EntryId };
- const page = await conversation?.entries(only, 1, undefined, durableContext(ctx));
+ const page = await conversation?.entries(only, 1, undefined, ctx);
const entry = page?.items[0];
const quote: ReplyTo | undefined = entry?.id === entryId ? quoteOf(entry) : undefined;
if (!quote) throw invalid("args", "That message cannot be replied to");
return quote;
}
-/** What the cells show of the document, and how many messages wait for a mention. */
-const Shown = defineState("p2.chat-mode-setting", {
- schema: obj({ enabled: bool(), name: AgentNameSchema, waiting: num() }),
+/** How many chat messages a mention would pass on now: computed from the transcript, which the view only windows. */
+const Waiting = defineState("p2.chat-mode-setting.waiting", {
+ schema: obj({ count: num() }),
version: 1,
- initial: () => ({ enabled: false, name: DEFAULT_AGENT_NAME, waiting: 0 }),
+ initial: () => ({ count: 0 }),
});
export default defineFacet((host) => {
@@ -103,73 +97,47 @@ export default defineFacet((host) => {
({ conversation }, self) => {
const id = conversation as ConversationId;
const harness = harnessOf(self);
- /** Where the agent's chat mode is (the main agent's on conversation 1); set when the object opens. */
+ // The conversation's view (shared with its viewers): its entries and queue change what waits for a mention.
+ const chat = self.get(Conversation, { conversation });
+ /** Where the agent's chat mode is (the main agent's on conversation 1); found when the document is followed. */
let holder = id;
- const read = async (ctx: Context) =>
- (await harness().durable.snapshot(ChatModeDoc, holder, durableContext(ctx))) ?? {
- enabled: false,
- name: DEFAULT_AGENT_NAME,
- };
- const shown = self.state(Shown);
- const commits = self.open(
- async (ctx) => {
- const durable = harness().durable;
- holder = await agentConversation(durable, id, durableContext(ctx));
- const doc = await read(ctx);
- await shown.change((draft) => {
- draft.enabled = doc.enabled;
- draft.name = doc.name;
+ const settings = self.follow(async (observer, ctx) => {
+ const durable = harness().durable;
+ holder = await agentConversation(durable, id, ctx);
+ const observed = await durable.observeDoc(ChatModeDoc, holder, observer, ctx);
+ if (observed !== undefined) return observed;
+ // Nobody switched it yet: the document is absent, and an absent address cannot be followed. Create it as it
+ // reads (off, the default name).
+ await durable.commit(async (tx) => {
+ await tx.doc(ChatModeDoc, holder);
+ }, ctx);
+ return durable.observeDoc(ChatModeDoc, holder, observer, ctx);
+ });
+ const waiting = self.state(Waiting);
+ const counted = deriveTyped(self, [settings, chat.view], ([doc, view]) => ({
+ enabled: doc.enabled,
+ newest: view?.entries.at(-1)?.id ?? 0,
+ inbox: (view?.docs[InboxDoc.definition.kind] ?? null) as InboxState | null,
+ }));
+ // The view holds only a window of the entries: the router's backwards scan counts what waits.
+ self.on(counted, async ({ value }, ctx) => {
+ const count = value.enabled
+ ? unmentioned(await recentEntries(harness().durable, id, ctx), value.inbox ?? undefined).length
+ : 0;
+ if (waiting.value.count !== count)
+ await waiting.change((draft) => {
+ draft.count = count;
}, ctx);
- if (!(await durable.conversation(id, ctx))) throw new Error(`Unknown conversation ${conversation}`);
- // The waiting count also follows the entries and the queue.
- return new CommitSignal(durable, (publication) =>
- publication.changes.some(
- (change) =>
- (change.type === "entry" && change.value.conversationId === id) ||
- (change.type === "document" &&
- change.record.kind === ChatModeDoc.definition.kind &&
- change.conversationId === holder) ||
- (change.type === "document" &&
- change.record.kind === InboxDoc.definition.kind &&
- change.conversationId === id),
- ),
- );
- },
- (signal) => signal.close(),
- );
- // The document is the truth; the cells show it. Subscribed before reading, so no commit is missed. The view
- // holds only a window of the entries, so the router's backwards scan counts what waits, while chat mode is on.
- self.onLive(async (ctx) => {
- const signal = signalOf(ctx);
- while (!signal.aborted) {
- const seen = commits.value.version;
- const doc = await read(ctx);
- const durable = harness().durable;
- const count = doc.enabled
- ? unmentioned(
- await recentEntries(durable, id, ctx),
- await durable.snapshot(InboxDoc, id, durableContext(ctx)),
- ).length
- : 0;
- const current = shown.value;
- if (current.enabled !== doc.enabled || current.name !== doc.name || current.waiting !== count)
- await shown.change((draft) => {
- draft.enabled = doc.enabled;
- draft.name = doc.name;
- draft.waiting = count;
- }, ctx);
- await commits.value.next(seen, signal).catch(() => {});
- }
});
const commit = async (ctx: Ctx, change: (doc: { enabled: boolean; name: string }) => void) => {
- const durable = await harness().durable.conversation(id, durableContext(ctx));
+ const durable = await harness().durable.conversation(id, ctx);
if (!durable) throw invalid("args", `Unknown conversation ${conversation}`);
- await durable.commit(async (tx) => change(await tx.doc(ChatModeDoc, holder)), durableContext(ctx));
+ await durable.commit(async (tx) => change(await tx.doc(ChatModeDoc, holder)), ctx);
};
return {
- enabled: self.derive([shown], ([value]) => value.enabled),
- name: self.derive([shown], ([value]) => value.name),
- waiting: self.derive([shown], ([value]) => value.waiting),
+ enabled: self.derive([settings], ([doc]) => doc.enabled),
+ name: self.derive([settings], ([doc]) => doc.name),
+ waiting: self.derive([waiting], ([value]) => value.count),
set: async ({ enabled: on }, ctx) => {
await commit(ctx, (doc) => {
doc.enabled = on;
@@ -203,11 +171,7 @@ export default defineFacet((host) => {
"chat-mode": async ({ conversation, author, text, requestId, whenBusy, attachments, replyTo }, ctx) => {
const durable = harness().durable;
const id = conversation as ConversationId;
- const settings = await durable.snapshot(
- ChatModeDoc,
- await agentConversation(durable, id, durableContext(ctx)),
- durableContext(ctx),
- );
+ const settings = await durable.snapshot(ChatModeDoc, await agentConversation(durable, id, ctx), ctx);
if (!settings?.enabled) return undefined;
const chat = await ctx.get(Conversation, { conversation });
// The client names the entry; whom it quotes and what comes from the entry.
@@ -222,7 +186,7 @@ export default defineFacet((host) => {
};
return chat.write({ kind: CHAT_ENTRY, data, requestId, author }, ctx);
}
- const inbox = await durable.snapshot(InboxDoc, id, durableContext(ctx));
+ const inbox = await durable.snapshot(InboxDoc, id, ctx);
const earlier = unmentioned(await recentEntries(durable, id, ctx), inbox);
const files = await resolve(digestFiles(earlier, attachments), conversation, ctx);
const own = files.filter((file) => attachments.includes(file.id));
diff --git a/apps/p2/plugins/chat/contract.ts b/apps/p2/plugins/chat/contract.ts
index c2617b158..7af4435ee 100644
--- a/apps/p2/plugins/chat/contract.ts
+++ b/apps/p2/plugins/chat/contract.ts
@@ -83,25 +83,23 @@ const ConversationConfigureSchema = obj({
const RestoredSchema = arr(obj({ author: AuthorSchema, text: str() }));
-/**
- * The conversation's user shell command (`!cmd`/`!!cmd`) while it runs and until its `p2.shell` entry is placed:
- * the tail of its output (src/runtime/shell.ts limits). Null when none.
- */
-export const ShellStateSchema = union([
- obj({
- id: str(),
- command: str(),
- author: AuthorSchema,
- includeInContext: bool(),
- output: str(),
- truncated: bool(),
- running: bool(),
- exitCode: opt(num()),
- cancelled: bool(),
- error: opt(str()),
- }),
- nul(),
-]);
+/** A user shell command (`!cmd`/`!!cmd`) while it runs and until its `p2.shell` entry is placed. */
+export const LiveShellSchema = obj({
+ id: str(),
+ command: str(),
+ author: AuthorSchema,
+ includeInContext: bool(),
+ /** The tail of its output (src/runtime/shell.ts limits). */
+ output: str(),
+ truncated: bool(),
+ running: bool(),
+ exitCode: opt(num()),
+ cancelled: bool(),
+ error: opt(str()),
+});
+export type LiveShell = Static;
+/** The conversation's user shell command; null when none. */
+export const ShellStateSchema = union([LiveShellSchema, nul()]);
export type ShellState = Static;
/** pi's text when a second command is started while one runs (interactive-mode `isBashRunning`). */
export const SHELL_BUSY = "A bash command is already running. Press Esc to cancel it first.";
@@ -109,7 +107,7 @@ export const ShellSchema = obj({
command: str({ minLength: 1, maxLength: 100_000 }),
/** `!` (true): the output joins the agent's context; `!!` (false): display only. */
includeInContext: bool(),
- /** Client-chosen; a retried call with the same ID runs once (scoped to the author). */
+ /** Client-chosen, the command's `shellId` (`liveShell.id`); a retried call with the same ID runs once (per author). */
requestId: str({ minLength: 1, maxLength: 200 }),
});
@@ -165,7 +163,8 @@ export type Submit = Static;
* watch, and the operations that need an author, which callers pass explicitly.
*/
export const Conversation = local("p2.conversation", {
- version: 1,
+ /** 2: user shell commands moved to `ShellCommands` (chat/shell.ts). */
+ version: 2,
params: { conversation: param.int() },
members: {
/** The view's window, as every viewer holds it. */
@@ -202,12 +201,6 @@ export const Conversation = local("p2.conversation", {
abortCompaction: method(undefined, obj({ aborted: int({ minimum: 0 }) })),
/** Withdraws `authorId`'s queued inputs (by the server-stamped author) in submission order. */
dequeueOwn: method(obj({ authorId: str() }), obj({ restored: RestoredSchema, skipped: int({ minimum: 0 }) })),
- /** The running (or just finished, not yet placed) user shell command. */
- shell: ShellStateSchema,
- /** Runs a user shell command on the conversation's environment; one at a time per conversation. */
- runShell: method(obj({ ...propertiesOf(ShellSchema), author: AuthorSchema }), obj({ shellId: str() })),
- /** Cancels the running command `shellId`; false when it is not the running one. */
- abortShell: method(obj({ shellId: str() }), obj({ aborted: bool() })),
},
});
diff --git a/apps/p2/plugins/chat/session-start.ts b/apps/p2/plugins/chat/session-start.ts
index d911f59b5..4ad48ae7c 100644
--- a/apps/p2/plugins/chat/session-start.ts
+++ b/apps/p2/plugins/chat/session-start.ts
@@ -4,16 +4,21 @@
* exists, the messages move to its main conversation's outbox, which delivers them (uploads, template expansion,
* retries), and the page opens the session.
*
- * One start per page, kept for the page's lifetime: messages sent while the session is being created join it. A
- * creation the server refuses (a directory that does not exist, an unknown model) puts the messages back into the
- * composer; a failure of the connection is tried again, at most `GIVE_UP` times while connected. A creation whose answer
- * was lost with the connection is tried again too and may leave an empty session behind.
+ * One start per page, kept for the page's lifetime: messages sent while the session is being created join it. The page
+ * chooses the session's ID at the first Send (`Sessions.create` takes it), so a creation whose answer was lost with
+ * the connection is tried again with the same ID and answers the same session. A creation the server refuses (a
+ * directory that does not exist, an unknown model) puts the messages back into the composer; a failure of the
+ * connection is tried again, at most `GIVE_UP` times while connected.
*/
import type { ModelChoice } from "@p2/app/v1/web";
import { GIVE_UP, type Outgoing, outboxOf, retryDelay } from "./outbox.ts";
-/** Where and with which model the session starts (as chosen at the first Send), and the messages it starts with. */
+/**
+ * The session's ID, where and with which model it starts (as chosen at the first Send), and the messages it starts
+ * with.
+ */
export type Start = {
+ readonly session: string;
readonly location: { readonly env: string | null; readonly cwd: string };
readonly choice: ModelChoice;
readonly messages: readonly Outgoing[];
@@ -21,8 +26,8 @@ export type Start = {
/** How the connected new-session screen creates the session, and what it does with the outcome. */
export type Creator = {
- /** Creates the session; resolves with its ID. */
- create(start: Start): Promise;
+ /** Creates the session `start.session` (again, after a lost answer: the server answers the same row). */
+ create(start: Start): Promise;
/** Whether `error` refuses the creation for good; anything else is retried. */
refused(error: unknown): boolean;
/** The session exists and its main conversation's outbox has the messages. */
@@ -51,7 +56,7 @@ export class SessionStart {
};
snapshot = (): Start | undefined => this.#start;
- /** Adds `message`; the first one's `place` decides where and with which model the session starts. */
+ /** Adds `message`; the first one's `place` decides the session's ID, where and with which model it starts. */
add(message: Outgoing, place: Omit): void {
this.#set(
this.#start
@@ -83,7 +88,8 @@ export class SessionStart {
this.#running = true;
clearTimeout(this.#retry);
try {
- const session = await creator.create(start);
+ await creator.create(start);
+ const { session } = start;
this.#attempt = 0;
// Read after the creation: messages sent meanwhile go with the first.
const messages = this.#start?.messages ?? [];
diff --git a/apps/p2/plugins/chat/shell.ts b/apps/p2/plugins/chat/shell.ts
new file mode 100644
index 000000000..d751df267
--- /dev/null
+++ b/apps/p2/plugins/chat/shell.ts
@@ -0,0 +1,305 @@
+// User shell commands (`!cmd`/`!!cmd`) of the worker's conversations: each command is a job whose state shows its
+// output while it runs; the run places the command's `p2.shell` entry before it ends, so the live block gives way to
+// the entry without a gap. One command at a time per conversation; the session neither moves nor clones while one runs.
+
+import type { ConversationId, Harness as DurableHarness } from "@earendil-works/pi-durable";
+import { bool, int, map, nul, num, obj, opt, type Static, str, union } from "@earendil-works/pi-schema";
+import {
+ authorSender,
+ defineJob,
+ defineState,
+ defineTypedState,
+ deriveTyped,
+ type FacetHost,
+ type JobCtx,
+ type JsonValue,
+ literal,
+ local,
+ method,
+ type P2Principal,
+ SHELL_ENTRY,
+ SHELL_MAX_BYTES,
+ SHELL_MAX_LINES,
+ type ShellData,
+ senderText,
+ shellContextText,
+ shellTail,
+ until,
+ withoutAbortSignal,
+} from "@p2/app/v1";
+import { conversationEnvironment, type ExecutionEnvironments, harnessOf, workerOf } from "@p2/app/v1/worker";
+import { type LiveShell, LiveShellSchema, SHELL_BUSY } from "./contract.ts";
+
+const CommandSchema = obj({
+ conversation: int({ minimum: 1 }),
+ command: str({ minLength: 1, maxLength: 100_000 }),
+ /** `!` (true): the output joins the agent's context; `!!` (false): display only. */
+ includeInContext: bool(),
+ /** Closed, unlike the stored entry's author: job arguments are checked like method arguments. */
+ author: obj({ kind: union([literal("owner"), literal("guest")]), id: str(), name: str() }),
+ /** The caller's ID for the command, its `shellId`: a client shows the command under it before the state arrives. */
+ requestId: str({ minLength: 1, maxLength: 200 }),
+});
+type Command = Static;
+
+/**
+ * The worker's user shell commands (host-internal): each conversation's running command, and how to start and cancel
+ * one. Chat's views show their conversation's command and pass the caller as its author.
+ */
+export const ShellCommands = local("p2.shell-commands", {
+ version: 1,
+ params: {},
+ members: {
+ /** The running (or finished, its entry not placed yet) command of each conversation, by conversation. */
+ shells: map(LiveShellSchema),
+ /**
+ * Starts a command; refuses while its conversation runs one (`SHELL_BUSY`). Its `shellId` is the caller's
+ * `requestId`; a retried `requestId` of the same author starts nothing (among the last 100 starts).
+ */
+ start: method(CommandSchema, obj({ shellId: str() })),
+ /** Cancels the running command `shellId`; resolves once it shows as cancelled, false when it is not running. */
+ abort: method(obj({ shellId: str() }), obj({ aborted: bool() })),
+ },
+});
+
+/** How often the live output is published at most (the bash tool's sampling, 100 KiB/s). */
+const SAMPLE_MS = 200;
+
+const OutputSchema = obj({
+ output: str(),
+ truncated: bool(),
+ running: bool(),
+ cancelled: bool(),
+ exitCode: opt(num()),
+ /** Where the environment kept the complete output, when it spilled. */
+ fullOutputPath: opt(str()),
+ error: opt(str()),
+});
+type Output = Static;
+
+/** A command's live output: the run's state document. */
+const OutputDoc = defineTypedState("p2.shell-commands.output", {
+ schema: OutputSchema,
+ version: 1,
+ initial: () => ({ output: "", truncated: false, running: true, cancelled: false }),
+});
+
+/** 2: the command's `requestId` (its `shellId`). */
+const ShellJob = defineJob("p2.shell-commands.run", {
+ version: 2,
+ args: CommandSchema,
+ result: nul(),
+ state: OutputDoc,
+});
+
+type RunCtx = JobCtx;
+
+/** Request keys (`author:requestId`) of recent starts and the command each started, oldest first. */
+const Admitted = defineState("p2.shell-commands.admitted", {
+ schema: obj({ starts: map(str()) }),
+ version: 1,
+ initial: () => ({ starts: {} }),
+});
+
+const MAX_ADMITTED = 100;
+
+/** Serves `ShellCommands` (the chat worker facet). */
+export function serveShellCommands(host: FacetHost): void {
+ host.serve(ShellCommands, (_params, self) => {
+ const harness = harnessOf(self);
+ const worker = workerOf(self);
+ const admitted = self.state(Admitted);
+ const job = self.job(ShellJob, {
+ run: async (command, ctx) => {
+ try {
+ await runCommand(command, harness().environments, ctx);
+ await placeEntry(command, ctx, harness().durable);
+ return null;
+ } finally {
+ worker().liveness.add("activeCalls", -1);
+ }
+ },
+ });
+ const shells = deriveTyped(self, [job.running], ([runs]) => {
+ const byConversation: Record = {};
+ for (const run of runs)
+ if (run.phase !== "blocked") byConversation[run.args.conversation] = liveShell(run.args, run.state);
+ return byConversation;
+ });
+ const running = (shellId: string) =>
+ Object.values(shells.value).some((shell) => shell.id === shellId && shell.running);
+ return {
+ shells,
+ start: async (command, ctx) => {
+ // A running command is work the session's fence waits for (a move, a clone), as a call is: counted from
+ // before its start (the caller's own call still counts) until its run ends.
+ worker().liveness.add("activeCalls", 1);
+ let started = false;
+ try {
+ const result = await self.tx(async (tx) => {
+ const starts = (await tx.draft(admitted)).starts;
+ const key = `${command.author.id}:${command.requestId}`;
+ if (starts[key] !== undefined) return { started: false };
+ if (shells.value[command.conversation] !== undefined) throw new Error(SHELL_BUSY);
+ starts[key] = tx.start(job, command).id;
+ const keys = Object.keys(starts);
+ for (const old of keys.slice(0, Math.max(0, keys.length - MAX_ADMITTED))) delete starts[old];
+ return { started: true };
+ }, ctx);
+ started = result.started;
+ return { shellId: command.requestId };
+ } finally {
+ if (!started) worker().liveness.add("activeCalls", -1);
+ }
+ },
+ abort: async ({ shellId }, ctx) => {
+ if (!running(shellId)) return { aborted: false };
+ // `abort` resolves once the run ended, after placing its entry (at the next boundary while the agent works):
+ // the caller waits only until the command shows as cancelled.
+ const run = job.running.value.find((each) => each.phase !== "blocked" && each.args.requestId === shellId);
+ if (run) await Promise.race([job.abort(run.id, ctx), until(shells, () => !running(shellId), ctx)]);
+ return { aborted: true };
+ },
+ };
+ });
+}
+
+/** A run as Chat shows it. */
+function liveShell(command: Command, state: Output): LiveShell {
+ return {
+ id: command.requestId,
+ command: command.command,
+ author: command.author,
+ includeInContext: command.includeInContext,
+ output: state.output,
+ truncated: state.truncated,
+ running: state.running,
+ cancelled: state.cancelled,
+ ...(state.exitCode === undefined ? {} : { exitCode: state.exitCode }),
+ ...(state.error === undefined ? {} : { error: state.error }),
+ };
+}
+
+/**
+ * Runs the command where its conversation works (a subagent's own location may be on another machine than the
+ * session's), sampling its output into the run's state, which ends with how it ended.
+ */
+async function runCommand(command: Command, environments: ExecutionEnvironments, ctx: RunCtx): Promise {
+ let output = "";
+ let truncated = false;
+ let timer: ReturnType | undefined;
+ // State changes are commits: chained, so the last one is the last written, and awaited before the run moves on.
+ let writes: Promise = Promise.resolve();
+ const write = (change: (draft: Output) => void) => {
+ writes = writes.then(() => ctx.state.change(change, ctx));
+ };
+ const sample = () => {
+ timer = undefined;
+ const [text, cut] = [output, truncated];
+ write((draft) => {
+ draft.output = text;
+ draft.truncated = cut;
+ });
+ };
+ let ended: Pick = {};
+ try {
+ const { location, env } = await conversationEnvironment(
+ environments,
+ command.conversation as ConversationId,
+ ctx,
+ );
+ const result = await env.exec(
+ command.command,
+ {
+ cwd: location.cwd,
+ inheritEnv: true,
+ spill: { afterBytes: SHELL_MAX_BYTES, afterLines: SHELL_MAX_LINES },
+ window: {
+ maxBytes: SHELL_MAX_BYTES,
+ maxLines: SHELL_MAX_LINES,
+ minIntervalMs: SAMPLE_MS,
+ bytesPerSecond: 100 * 1024,
+ },
+ onOutput: (text, _context, info) => {
+ // The environment omitted output outside the kept tail: what follows replaces it.
+ if (info.skipped) {
+ output = "";
+ truncated = true;
+ }
+ const tail = shellTail(output + text);
+ output = tail.text;
+ truncated ||= tail.truncated;
+ timer ??= setTimeout(sample, SAMPLE_MS);
+ },
+ },
+ ctx,
+ );
+ if (result.ok)
+ ended = {
+ exitCode: result.value.exitCode,
+ ...(result.value.spillPath ? { fullOutputPath: result.value.spillPath } : {}),
+ };
+ else if (result.error.code !== "aborted")
+ ended = {
+ error: result.error.code === "timeout" ? "Command timed out" : result.error.message,
+ ...(result.error.spillPath ? { fullOutputPath: result.error.spillPath } : {}),
+ };
+ } catch (error) {
+ ended = { error: error instanceof Error ? error.message : String(error) };
+ } finally {
+ clearTimeout(timer);
+ }
+ // A command's last newline ends its output; it is not an empty last line.
+ const final = output.replace(/\r?\n$/, "");
+ const cancelled = ctx.abortSignal?.aborted === true;
+ write((draft) => {
+ Object.assign(draft, { output: final, truncated, running: false, cancelled, ...ended });
+ });
+ await writes;
+}
+
+/**
+ * Places the finished command as its `p2.shell` entry and waits until it is placed. A write never starts a run; while
+ * the agent works it is placed at the run's next boundary. A cancelled command is placed too: without the run's abort
+ * signal.
+ */
+async function placeEntry(command: Command, ctx: RunCtx, durable: DurableHarness): Promise {
+ const { output, truncated, cancelled, exitCode, fullOutputPath, error } = ctx.state.value;
+ const data: ShellData = {
+ author: command.author,
+ command: command.command,
+ output,
+ cancelled,
+ truncated,
+ includeInContext: command.includeInContext,
+ ...(exitCode === undefined ? {} : { exitCode }),
+ ...(fullOutputPath === undefined ? {} : { fullOutputPath }),
+ ...(error === undefined ? {} : { error }),
+ };
+ const context = withoutAbortSignal(ctx);
+ const conversation = await durable.conversation(command.conversation as ConversationId, context);
+ if (conversation === undefined) throw new Error(`Unknown conversation ${command.conversation}`);
+ const model = [
+ {
+ role: "user" as const,
+ content: [
+ { type: "text" as const, text: senderText(authorSender(command.author)) },
+ { type: "text" as const, text: shellContextText(data) },
+ ],
+ timestamp: Date.now(),
+ },
+ ];
+ const submission = await conversation.submit(
+ {
+ type: "write",
+ entry: {
+ kind: SHELL_ENTRY,
+ data: data as unknown as JsonValue,
+ ...(command.includeInContext ? { model } : {}),
+ },
+ requestId: `${command.author.id}:shell:${ctx.jobId}`,
+ },
+ context,
+ );
+ await submission.wait(context);
+}
diff --git a/apps/p2/plugins/chat/tui.ts b/apps/p2/plugins/chat/tui.ts
index 21bbb90c1..76cb089a2 100644
--- a/apps/p2/plugins/chat/tui.ts
+++ b/apps/p2/plugins/chat/tui.ts
@@ -14,8 +14,8 @@ import {
truncateToWidth,
VStack,
} from "@earendil-works/pi-tui";
-import type { Ctx, Held, HeldIn } from "@p2/app/v1";
-import { BACKGROUND_CONTEXT, FileIndex, followRegistry, liveIn, serveKeyed, statusOf } from "@p2/app/v1";
+import type { Held, HeldIn } from "@p2/app/v1";
+import { BACKGROUND_CONTEXT, FileIndex, followRegistry, liveIn, serveKeyed, statusOf, whenLive } from "@p2/app/v1";
import {
BashExecutionComponent,
buildTranscript,
@@ -31,6 +31,7 @@ import {
identityKey,
lineText,
type MarkdownTransformer,
+ type OptimisticAdd,
ordered,
type Pages,
pagesWatchable,
@@ -67,6 +68,17 @@ import { SessionTree } from "../tree/contract.ts";
import { Chat, type EntryDisplay, SHELL_BUSY, type ShellState, TranscriptEntries } from "./contract.ts";
import { CHAT_COMMANDS, formatSize, type LauncherUpload, registerLauncherAnswers } from "./tui-commands.ts";
+/** A message sent from here that the server does not show yet: in the transcript, or the queue while a run is busy. */
+type Outgoing = {
+ readonly id: string;
+ readonly text: string;
+ readonly mode: "steer" | "followUp";
+ readonly files: readonly LauncherUpload[];
+};
+
+/** No message in flight: one array, so a transcript rebuild sees "unchanged". */
+const NO_OUTGOING: readonly Outgoing[] = [];
+
/** pi: a second Ctrl+C within this window exits; a second Escape runs `doubleEscapeAction`. */
const DOUBLE_CTRL_C_MS = 500;
const DOUBLE_ESCAPE_MS = 500;
@@ -114,7 +126,23 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi {
const agent = this.view?.docs["pi.agent"] as { cwd?: unknown } | undefined;
return typeof agent?.cwd === "string" ? agent.cwd : (this.rows.find((row) => row.id === this.session)?.cwd ?? "");
}
- private ref: (() => Held) | undefined;
+ private ref: Held | undefined;
+ /** Messages in flight (`send`): shown at once, the editor already cleared; a refused one comes back to the editor. */
+ private outgoing: () => readonly Outgoing[] = () => NO_OUTGOING;
+ private sending: OptimisticAdd | undefined;
+ /** A `!cmd` in flight: its block shows as running before the server's state does. */
+ private starting: () => ShellState = () => null;
+ private start: OptimisticAdd | undefined;
+ /** A `/compact` in flight: the compaction indicator shows before `pi.live` does. */
+ private compacting: () => boolean = () => false;
+ private compactNow: OptimisticAdd | undefined;
+ /** The session catalog (`/fork`, `/clone`). */
+ private catalog: Held;
+ /**
+ * The viewed session's branches (`/tree`), held from its first use until another session is viewed: a switch of
+ * the main conversation changes the view's identity but must not release it while `navigate` is in flight.
+ */
+ private tree: { session: string; scope: TuiScope; ref: Held } | undefined;
private view: ConversationView | null | undefined;
/** The conversation's user shell command while it runs (`Chat.liveShell`), and its component (the transcript tail). */
private liveShell: ShellState = null;
@@ -134,9 +162,6 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi {
private current: Transcript | undefined;
private synced: unknown[] = [];
private disposed = false;
- private sending = false;
- /** A send asked for while the conversation was connecting. */
- private waiting: "steer" | "followUp" | undefined;
private lastCtrlC = 0;
private lastEscape = 0;
private indicator: StatusIndicator | undefined;
@@ -279,7 +304,7 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi {
);
// The session's cwd: tool paths display relative to it.
this.sessions = new TuiScope(runtime);
- this.sessions.watch(Sessions, {}, (ref, follow) => {
+ this.catalog = this.sessions.watch(Sessions, {}, (ref, follow) => {
const viewer = liveIn(ref, "owner", "guest");
if (viewer)
follow(viewer.sessions, (rows) => {
@@ -330,7 +355,6 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi {
if (identity !== this.identity) this.switchTo(identity);
this.transcript.setState({ expanded: runtime.expandedTools, showThinking: runtime.showThinking });
this.sync();
- if (this.waiting && runtime.connected && this.participant()) this.send(this.waiting);
}
private switchTo(identity: string | undefined): void {
@@ -339,14 +363,18 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi {
this.scope?.dispose();
this.scope = undefined;
this.ref = undefined;
+ this.outgoing = () => NO_OUTGOING;
+ this.sending = undefined;
+ this.starting = () => null;
+ this.start = undefined;
+ this.compacting = () => false;
+ this.compactNow = undefined;
this.view = undefined;
this.transcriptStore = undefined;
this.files = undefined;
this.current = undefined;
this.liveShell = null;
this.synced = [];
- this.sending = false;
- this.waiting = undefined;
this.replyTo = undefined;
this.transcript.reset();
this.identity = identity;
@@ -359,6 +387,12 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi {
const conversation = this.conversation;
const scope = new TuiScope(runtime);
this.scope = scope;
+ [this.outgoing, this.sending] = scope.optimistic(
+ (): readonly Outgoing[] => NO_OUTGOING,
+ (list, message: Outgoing) => [...list, message],
+ );
+ [this.starting, this.start] = scope.optimistic((): ShellState => null);
+ [this.compacting, this.compactNow] = scope.optimistic(() => false);
this.ref = scope.watch(Chat, { session, conversation }, (ref, follow) => {
follow(ref.view, (view) => {
if (view !== undefined) this.view = view;
@@ -373,29 +407,36 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi {
});
this.transcriptStore = store;
follow(pagesWatchable(store), () => {});
- if (ref.variant !== "participant") return;
- scope.watch(Drafts, { session, conversation }, (drafts, subscribe) => {
- subscribe(drafts.drafts, (items) => {
- if (items?.length)
- runtime.perform(
- drafts
- .claim({ ids: items.map((item) => item.id) }, BACKGROUND_CONTEXT)
- .then(({ texts }) => this.restore(identityKey(session, conversation), texts)),
- );
- });
- });
- // `@` and Tab file completion (participants, as the workspace); a server without the plugin has none.
- if (!this.files)
- try {
- this.files = scope.watch(FileIndex, { session, conversation }, () => {});
- } catch {}
+ if (ref.variant === "participant") this.holdWriterParts(scope, session, conversation);
});
+ // Who may write is known before the conversation is live (the role, a guest's grant): no wait for it.
+ if (runtime.role === "owner" || runtime.guestMode() === "full")
+ this.holdWriterParts(scope, session, conversation);
+ }
+
+ /**
+ * What a writer of the conversation holds besides it: its drafts withdrawn by others (claimed back into the editor)
+ * and its file index (`@` and Tab completion). Once per view.
+ */
+ private holdWriterParts(scope: TuiScope, session: string, conversation: number): void {
+ if (this.files) return;
+ scope.watch(Drafts, { session, conversation }, (drafts, subscribe) => {
+ subscribe(drafts.drafts, (items) => {
+ if (items?.length)
+ this.runtime.perform(
+ drafts
+ .claim({ ids: items.map((item) => item.id) }, BACKGROUND_CONTEXT)
+ .then(({ texts }) => this.restore(identityKey(session, conversation), texts)),
+ );
+ });
+ });
+ this.files = scope.watch(FileIndex, { session, conversation }, () => {});
}
/** The viewed conversation's file index once live (P7b). */
- private files: (() => Held) | undefined;
+ private files: Held | undefined;
private fileSource(): FileSource | undefined {
- const ref = liveIn(this.files?.(), "reader");
+ const ref = liveIn(this.files, "reader");
return (
ref && {
complete: (args) => ref.complete(args, BACKGROUND_CONTEXT),
@@ -446,18 +487,23 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi {
}
private participant(): HeldIn | undefined {
- const ref = this.ref?.();
- return liveIn(ref, "participant");
+ return liveIn(this.ref, "participant");
+ }
+
+ /** Whether the reference is live in a variant that cannot write (a viewer). */
+ private get readOnlyView(): boolean {
+ return this.ref !== undefined && statusOf(this.ref) === "live" && this.ref.variant !== "participant";
}
private sync(): void {
- const ref = this.ref?.();
+ const ref = this.ref;
const view = this.view;
const pages = this.pages;
- const inputs = [pages, this.displays, this.toolRenderers, this.resolvers, this.entryRenderers];
+ const outgoing = this.outgoing();
+ const inputs = [pages, outgoing, this.displays, this.toolRenderers, this.resolvers, this.entryRenderers];
if (inputs.length !== this.synced.length || inputs.some((input, index) => input !== this.synced[index])) {
this.synced = inputs;
- this.current = pages ? buildTranscript(pages.view, this.display, pages.filling) : undefined;
+ this.current = pages ? this.withOutgoing(buildTranscript(pages.view, this.display, pages.filling)) : undefined;
const entries = (pages?.view.items ?? []).flatMap((item) => (item.kind === "entry" ? [item.entry] : []));
this.transcript.sync(this.current, entries);
}
@@ -505,7 +551,7 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi {
/** The running command as pi's bash execution component at the end of the transcript. */
private syncShell(): void {
- const live = this.liveShell;
+ const live = this.shownShell;
if (!live) {
this.shellView?.component.dispose();
this.shellView = undefined;
@@ -532,32 +578,70 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi {
this.transcript.tail = component;
}
- /** `!cmd`/`!!cmd`: runs the command (`Chat.shell`); one at a time, as pi. */
+ /** The transcript with the messages in flight: at its end while idle, else in the queue (`syncPending`). */
+ private withOutgoing(transcript: Transcript): Transcript {
+ const outgoing = this.outgoing();
+ if (!outgoing.length || transcript.busy) return transcript;
+ const author = this.ownAuthor();
+ const items = outgoing.map((message) => ({
+ type: "user" as const,
+ key: `outgoing-${message.id}`,
+ text: message.text,
+ images: [],
+ files: message.files.map((file) => file.name),
+ author: { ...author, id: "" },
+ }));
+ return { ...transcript, items: [...transcript.items, ...items] };
+ }
+
+ /** Who the server will record as this visitor (for messages and commands shown before it does). */
+ private ownAuthor(): { kind: "owner" | "guest"; name: string } {
+ return this.runtime.role === "guest"
+ ? { kind: "guest", name: this.runtime.grant?.name ?? "Guest" }
+ : { kind: "owner", name: "Owner" };
+ }
+
+ /** The running command as shown: the server's, else one just started from here. */
+ private get shownShell(): ShellState {
+ return this.liveShell ?? this.starting();
+ }
+
+ /**
+ * `!cmd`/`!!cmd`: runs the command (`Chat.shell`); one at a time, as pi. Its block shows as running at once and the
+ * editor clears; a refused command comes back to the editor with the error.
+ */
shell(command: string, includeInContext: boolean): void {
const ref = this.participant();
const identity = this.identity;
- if (!ref || !identity) {
- this.runtime.fail(
- this.ref?.() !== undefined && statusOf(this.ref()) === "live"
- ? "This conversation is read-only"
- : "Not connected yet",
- );
+ if (!ref || !identity || !this.start) {
+ this.runtime.fail(this.readOnlyView ? "This conversation is read-only" : "Not connected yet");
return;
}
- if (this.liveShell?.running) {
+ if (this.shownShell?.running) {
this.runtime.notify(SHELL_BUSY, "warning");
return;
}
const draft = this.editor.getExpandedText();
+ this.editor.addToHistory(draft.trim());
+ this.editor.setText("");
+ this.runtime.drafts.set(identity, "");
this.runtime.clearNotices();
this.scroll.scrollToEnd();
+ const requestId = randomUUID();
+ const guess = {
+ id: requestId,
+ command,
+ author: { ...this.ownAuthor(), id: "" },
+ includeInContext,
+ output: "",
+ truncated: false,
+ running: true,
+ cancelled: false,
+ };
this.runtime.perform(
- ref.shell({ command, includeInContext, requestId: randomUUID() }, BACKGROUND_CONTEXT).then(() => {
- if (!this.disposed) this.editor.addToHistory(draft.trim());
- if (!this.disposed && identity === this.identity && this.editor.getExpandedText() === draft)
- this.editor.setText("");
- if (this.runtime.drafts.get(identity) === draft) this.runtime.drafts.set(identity, "");
- this.runtime.changed();
+ this.start(guess, ref.shell({ command, includeInContext, requestId }, BACKGROUND_CONTEXT)).catch((error) => {
+ this.restore(identity, [draft]);
+ throw error;
}),
);
}
@@ -567,30 +651,26 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi {
const theme = this.runtime.theme.current;
this.pending.clear();
const queued = this.current?.queued ?? [];
+ // Messages in flight while a run is busy wait in the queue, as the server will place them; before the transcript
+ // arrived (the conversation still connecting) they show here too.
+ const outgoing = !this.current || this.current.busy ? this.outgoing() : NO_OUTGOING;
const replying = this.canReply ? this.replyTo : undefined;
- if (queued.length === 0 && !this.sending && !this.waiting && !replying) return;
+ if (queued.length === 0 && outgoing.length === 0 && !replying) return;
this.pending.addChild(new Text("", 0, 0));
if (replying)
this.pending.addChild(
new TruncatedText(theme.fg("accent", `↪ Replying to ${lineText(replying.name)} · /reply cancels`), 1, 0),
);
- for (const input of queued)
+ const label = (mode: string) => (!this.current ? "Sending" : mode === "followUp" ? "Follow-up" : "Steering");
+ for (const input of [...queued, ...outgoing])
this.pending.addChild(
- new TruncatedText(
- theme.fg("dim", `${input.mode === "followUp" ? "Follow-up" : "Steering"}: ${lineText(input.text)}`),
- 1,
- 0,
- ),
+ new TruncatedText(theme.fg("dim", `${label(input.mode)}: ${lineText(input.text)}`), 1, 0),
);
if (queued.length && participant) {
const hint = this.runtime.ctx.keyText("app.message.dequeue");
if (hint)
this.pending.addChild(new TruncatedText(theme.fg("dim", `↳ ${hint} to edit your queued messages`), 1, 0));
}
- if (this.sending || this.waiting)
- this.pending.addChild(
- new TruncatedText(theme.fg("dim", this.sending ? "Sending…" : "Sending when connected…"), 1, 0),
- );
}
/** DTUI `#syncStatus`: retry, compaction, running tool or working, from `pi.live`. */
@@ -599,7 +679,10 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi {
const tui = this.runtime.tui;
const live = this.view?.docs["pi.live"] as unknown as LiveState | undefined;
const generation = live?.generation;
- const compaction = live?.compactions?.[0];
+ // A `/compact` in flight shows as a manual compaction until `pi.live` has it.
+ const compaction =
+ live?.compactions?.[0] ??
+ (this.compacting() ? { trigger: "manual" as const, attempt: 1, retry: undefined } : undefined);
const running = live?.tools?.find((slot) => slot.status === "running");
const abort = `(${ctx.keyText("app.interrupt")} to abort)`;
let key = "";
@@ -639,61 +722,62 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi {
if (!participant && this.indicator) this.status.addChild(this.indicator);
}
+ /**
+ * Sends the editor's message the way the web's outbox does: it leaves the editor and shows in the transcript (the
+ * queue while a run is busy) at once, and is delivered once the conversation is live (a reload, a reconnect). A
+ * refused message comes back to the editor with the error; a retry of the same text reuses its request ID, so it
+ * is admitted once.
+ */
send(mode: "steer" | "followUp"): void {
- const ref = this.participant();
const text = this.editor.getExpandedText().trim();
- const files = this.identity ? (this.attachments.get(this.identity) ?? []) : [];
- if ((!text && !files.length) || this.sending) return;
- const current = this.ref?.();
- if (current !== undefined && statusOf(current) === "live" && current.variant !== "participant") {
+ const identity = this.identity;
+ const files = identity ? (this.attachments.get(identity) ?? []) : [];
+ const ref = this.ref;
+ if ((!text && !files.length) || !identity || !ref || !this.sending) return;
+ if (this.readOnlyView) {
this.runtime.fail("This conversation is read-only");
return;
}
- if (!this.runtime.connected || !ref) {
- // Still connecting (a reload, a reconnect): sent once the conversation is live, with the same request ID.
- this.waiting = mode;
- this.runtime.changed();
- return;
- }
- this.waiting = undefined;
- const identity = this.identity!;
const draft = this.editor.getExpandedText();
let submission = this.runtime.submissions.get(identity);
if (submission?.text !== text || submission.mode !== mode) {
submission = { text, mode, id: randomUUID() };
this.runtime.submissions.set(identity, submission);
}
- this.sending = true;
+ const replyTo = this.canReply ? this.replyTo : undefined;
+ this.attachments.set(identity, []);
+ this.replyTo = undefined;
+ this.editor.addToHistory(text);
+ this.editor.setText("");
+ this.runtime.drafts.set(identity, "");
this.runtime.clearNotices();
this.scroll.scrollToEnd();
- this.runtime.perform(
- ref
- .send(
+ const sent = submission;
+ const delivered = (async () => {
+ try {
+ // Waits through a connecting conversation; calls made while it reconnects wait for it too.
+ await whenLive(ref);
+ const writer = liveIn(ref, "participant");
+ if (!writer) throw new Error("This conversation is read-only");
+ await writer.send(
{
text,
- requestId: submission.id,
+ requestId: sent.id,
whenBusy: mode,
...(files.length ? { attachments: files.map((file) => file.id) } : {}),
- ...(this.canReply && this.replyTo ? { replyTo: this.replyTo.entryId } : {}),
+ ...(replyTo ? { replyTo: replyTo.entryId } : {}),
},
BACKGROUND_CONTEXT,
- )
- .then(() => {
- // Sent: these attachments belong to the message now (later uploads stay pending).
- const left = (this.attachments.get(identity) ?? []).filter((file) => !files.includes(file));
- this.attachments.set(identity, left);
- if (identity === this.identity) this.replyTo = undefined;
- if (!this.disposed) this.editor.addToHistory(text);
- if (!this.disposed && identity === this.identity && this.editor.getExpandedText() === draft)
- this.editor.setText("");
- if (this.runtime.drafts.get(identity) === draft) this.runtime.drafts.set(identity, "");
- if (this.runtime.submissions.get(identity) === submission) this.runtime.submissions.delete(identity);
- })
- .finally(() => {
- if (identity === this.identity) this.sending = false;
- this.runtime.changed();
- }),
- );
+ );
+ if (this.runtime.submissions.get(identity) === sent) this.runtime.submissions.delete(identity);
+ } catch (error) {
+ this.restore(identity, [draft]);
+ this.attachments.set(identity, [...files, ...(this.attachments.get(identity) ?? [])]);
+ if (identity === this.identity) this.replyTo ??= replyTo;
+ throw error;
+ }
+ })();
+ this.runtime.perform(this.sending({ id: sent.id, text, mode, files }, delivered));
}
private restore(identity: string, texts: string[]): void {
@@ -705,7 +789,7 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi {
}
private loadEarlier(): void {
- const ref = this.ref?.();
+ const ref = this.ref;
const store = this.transcriptStore;
if (ref === undefined || statusOf(ref) !== "live" || !store || this.earlierFailed.has(store)) return;
this.runtime.perform(
@@ -725,7 +809,7 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi {
/** Loads a gap's missing entries while it is in view; a failed load is not retried for that gap. */
private fillGap(gap: Gap): void {
- const ref = this.ref?.();
+ const ref = this.ref;
const store = this.transcriptStore;
if (
ref === undefined ||
@@ -800,20 +884,19 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi {
const session = this.session;
if (!session) return;
const title = () => this.rows.find((row) => row.id === session)?.title ?? "";
+ // One reference per viewed session: opening `/tree` again and each action reuse it.
+ if (this.tree?.session !== session) {
+ this.tree?.scope.dispose();
+ const scope = new TuiScope(runtime);
+ this.tree = { session, scope, ref: scope.watch(SessionTree, { session }, () => {}) };
+ }
+ const held = this.tree.ref;
runtime.perform(
- runtime.host.root(async (root) => {
- const ref = await root.get(SessionTree, { session });
- if (ref.variant !== "owner") throw new Error("Only the owner switches branches");
- const tree = await ref.tree(root);
- // Each action runs in a root of its own: the selector outlives this one.
- const act = (action: (tree: typeof ref, ctx: Ctx) => Promise) =>
- runtime.perform(
- runtime.host.root(async (inner) => {
- const owned = await inner.get(SessionTree, { session });
- if (owned.variant !== "owner") throw new Error("Only the owner switches branches");
- await action(owned, inner);
- }, BACKGROUND_CONTEXT),
- );
+ (async () => {
+ await whenLive(held);
+ const owner = liveIn(held, "owner");
+ if (!owner) throw new Error("Only the owner switches branches");
+ const tree = await owner.tree(BACKGROUND_CONTEXT);
const selector: TreeSelectorComponent = new TreeSelectorComponent(
runtime.ctx,
tree.nodes,
@@ -821,24 +904,29 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi {
new Map(tree.branches.map((branch) => [branch.conversation, branch.label ?? title()])),
(entry) => {
overlay.close();
- act(async (owned, ctx) => {
- const { conversation, text } = await owned.navigate({ entry }, ctx);
- const identity = identityKey(session, conversation);
- if (text !== null) runtime.drafts.set(identity, text);
- runtime.navigate("chat", session, conversation);
- // Already shown (the session continues in the same conversation): the editor takes the text now.
- if (text !== null && !this.disposed && this.identity === identity) this.editor.setText(text);
- });
+ runtime.perform(
+ owner.navigate({ entry }, BACKGROUND_CONTEXT).then(({ conversation, text }) => {
+ const identity = identityKey(session, conversation);
+ if (text !== null) runtime.drafts.set(identity, text);
+ runtime.navigate("chat", session, conversation);
+ // Already shown (the session continues in the same conversation): the editor takes the text now.
+ if (text !== null && !this.disposed && this.identity === identity) this.editor.setText(text);
+ }),
+ );
},
() => overlay.close(),
- (conversation, label) =>
- act(async (owned, ctx) => {
- await owned.rename({ conversation, label: label || null }, ctx);
- selector.setLabel(conversation, label || title());
- }),
+ // The label shows at once; a refused rename puts the old one back.
+ (conversation, label) => {
+ const before = tree.branches.find((branch) => branch.conversation === conversation)?.label ?? title();
+ selector.setLabel(conversation, label || title());
+ owner.rename({ conversation, label: label || null }, BACKGROUND_CONTEXT).catch((error: unknown) => {
+ selector.setLabel(conversation, before);
+ runtime.fail(error);
+ });
+ },
);
const overlay = runtime.ctx.overlay("tree", selector, { width: "100%", anchor: "bottom-center" });
- }, BACKGROUND_CONTEXT),
+ })(),
);
}
@@ -871,18 +959,17 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi {
(id) => {
overlay.close();
const text = messages.find((message) => message.id === id)?.text ?? "";
- runtime.perform(
- runtime.host.root(async (root) => {
- const sessions = await root.get(Sessions, {});
- if (sessions.variant !== "owner") throw new Error("Only the owner forks sessions");
- const created = await sessions.fork(
- { id: session, conversation: this.conversation, before: id },
- root,
- );
- runtime.drafts.set(identityKey(created.id, 1), text);
- runtime.navigate("chat", created.id, 1);
- runtime.notify("Forked to new session");
- }, BACKGROUND_CONTEXT),
+ const catalog = liveIn(this.catalog, "owner");
+ if (!catalog) return;
+ // The forked message waits in the new session's editor.
+ this.openCopy(
+ session,
+ (copy) =>
+ catalog.fork(
+ { id: session, conversation: this.conversation, before: id, copy },
+ BACKGROUND_CONTEXT,
+ ),
+ text,
);
},
() => overlay.close(),
@@ -892,28 +979,39 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi {
);
}
- /** pi's `/clone`: the whole session as a new one (`Sessions.clone`), opened. */
+ /** pi's `/clone`: the whole session as a new one (`Sessions.clone`), opened at once. */
cloneSession(): void {
- const runtime = this.runtime;
const session = this.session;
- if (!session) return;
- runtime.perform(
- runtime.host.root(async (root) => {
- const sessions = await root.get(Sessions, {});
- if (sessions.variant !== "owner") throw new Error("Only the owner clones sessions");
- const created = await sessions.clone({ id: session }, root);
- runtime.navigate("chat", created.id, 1);
- runtime.notify("Cloned to new session");
- }, BACKGROUND_CONTEXT),
- );
+ const catalog = liveIn(this.catalog, "owner");
+ if (!session || !catalog) return;
+ this.openCopy(session, (copy) => catalog.clone({ id: session, copy }, BACKGROUND_CONTEXT));
}
- /** `/compact [instructions]`: summarize earlier context now (Escape aborts it). */
+ /**
+ * Opens a copy of `session` (a clone, a fork) at once: this TUI chooses its ID (`copy`), the new conversation shows
+ * loading while its files are copied (a job; its worker starts after), with `draft` in its editor. A refused copy
+ * returns to `session` with the error.
+ */
+ private openCopy(session: string, call: (copy: string) => Promise, draft?: string): void {
+ const runtime = this.runtime;
+ const copy = randomUUID();
+ if (draft !== undefined) runtime.drafts.set(identityKey(copy, 1), draft);
+ runtime.navigate("chat", copy, 1);
+ call(copy).catch((error: unknown) => {
+ if (runtime.session === copy) runtime.navigate("chat", session);
+ runtime.fail(error);
+ });
+ }
+
+ /** `/compact [instructions]`: summarize earlier context now (Escape aborts it); its indicator shows at once. */
compact(instructions?: string): void {
const ref = this.participant();
- if (!ref) return;
+ if (!ref || !this.compactNow) return;
this.runtime.perform(
- ref.compact(instructions?.trim() ? { instructions: instructions.trim() } : {}, BACKGROUND_CONTEXT),
+ this.compactNow(
+ true,
+ ref.compact(instructions?.trim() ? { instructions: instructions.trim() } : {}, BACKGROUND_CONTEXT),
+ ),
);
}
@@ -1014,9 +1112,7 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi {
/** The editor keeps input while the conversation connects (a reload, a reconnect); read-only views drop it. */
handleInput(data: string): void {
- const ref = this.ref?.();
- if (ref !== undefined && statusOf(ref) === "live" && ref.variant !== "participant") return;
- this.editor.handleInput(data);
+ if (!this.readOnlyView) this.editor.handleInput(data);
}
/** Without the fullscreen layout (tests): transcript, dock. */
@@ -1037,6 +1133,7 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi {
this.shellView?.component.dispose();
this.transcript.dispose();
this.sessions.dispose();
+ this.tree?.scope.dispose();
this.scope?.dispose();
}
}
diff --git a/apps/p2/plugins/chat/web.tsx b/apps/p2/plugins/chat/web.tsx
index 248110ddc..fd29a535a 100644
--- a/apps/p2/plugins/chat/web.tsx
+++ b/apps/p2/plugins/chat/web.tsx
@@ -10,6 +10,7 @@ import {
isChordError,
liveIn,
serveKeyed,
+ UI_LINGER,
whenLive,
} from "@p2/app/v1";
import {
@@ -23,6 +24,7 @@ import {
closeLayers,
conversationRoute,
DownIcon,
+ errorText,
type FileCompletion,
FileCompletionPopover,
FillGap,
@@ -33,6 +35,7 @@ import {
keep,
keepSessions,
keptSizes,
+ knownMain,
loadKept,
locationLabel,
MenuIcon,
@@ -76,6 +79,7 @@ import {
useNavigation,
useNewSessionState,
useObject,
+ useOptimisticCall,
useOwner,
useReadable,
useRegistry,
@@ -110,8 +114,6 @@ import { Chat, ChatCosts, TranscriptEntries } from "./contract.ts";
import { type Outgoing, outboxOf } from "./outbox.ts";
import { sessionStart } from "./session-start.ts";
-const errorText = (error: unknown): string => (error instanceof Error ? error.message : String(error));
-
/** An upload the server answered with an error status. */
class UploadError extends Error {
readonly status: number;
@@ -207,6 +209,30 @@ const attachingOf = ({ files, setFiles, addFiles }: ReturnType setFiles((current) => current.filter((entry) => entry !== file)),
});
+/**
+ * The owner's model catalog and settings, with the default model as just chosen: `setDefault` shows the choice as the
+ * default at once (the picker's mark) and rolls back if the server refuses it.
+ */
+function useModelResources(owner: boolean, fail: (error: unknown) => void) {
+ const models = useObject(Models, owner ? {} : undefined);
+ const [resources, guess] = useOptimisticCall(
+ useReadable(models?.resources),
+ (snapshot, choice: ModelChoice) =>
+ snapshot && {
+ ...snapshot,
+ settings: { ...snapshot.settings, defaultModel: choice.model, thinkingLevel: choice.thinkingLevel },
+ },
+ fail,
+ );
+ const setDefault = (choice: ModelChoice) => {
+ if (models)
+ guess(choice, () =>
+ models.setDefault({ ...choice.model, thinkingLevel: choice.thinkingLevel }, BACKGROUND_CONTEXT),
+ );
+ };
+ return [resources, setDefault] as const;
+}
+
/** The model and thinking chip that opens the model sheet. */
function ModelControl({ resources, choice }: { resources: ResourceSnapshot | undefined; choice?: ModelChoice }) {
const name = choice ? modelName(resources, choice.model) : "Choose model";
@@ -490,7 +516,18 @@ function Dock({ frame, dockRef }: { frame: Frame; dockRef: (element: HTMLDivElem
);
}
-function TopBar({ session, child, owner = false }: { session?: Session; child?: Child; owner?: boolean }) {
+function TopBar({
+ session,
+ child,
+ owner = false,
+ moving = false,
+}: {
+ session?: Session;
+ child?: Child;
+ owner?: boolean;
+ /** The session is being moved (a relocation job runs). */
+ moving?: boolean;
+}) {
const connected = useConnected();
const nav = useNavigation();
const route = useRoute();
@@ -530,7 +567,7 @@ function TopBar({ session, child, owner = false }: { session?: Session; child?:
className="title-cwd"
onClick={() => openLayer(directoryPickerLayer(session.env ?? null, session.cwd))}
>
- {locationLabel(session.env, session.cwd)}
+ {moving ? "Moving…" : locationLabel(session.env, session.cwd)}
) : (
{locationLabel(session.env, session.cwd)}
@@ -663,8 +700,8 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers
// Taken while the object is live, a removed grant (null) included.
if (useStatus(access) === "live") lastGrant.current = liveGrant;
const grant = owner ? undefined : lastGrant.current;
- const models = useObject(Models, owner ? {} : undefined);
- const resources = useReadable(models?.resources);
+ const { fail } = useComposerState();
+ const [resources, setDefault] = useModelResources(owner, fail);
const subagents = liveIn(useObject(Subagents, {}), "reader");
const children = useReadable(subagents?.children);
const child = children?.[sessionId]?.find((entry) => entry.conversationId === conversationId);
@@ -744,10 +781,10 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers
// Who may write decides what shows (as Pi Mobile's `canWrite`), not whether the chat object is live yet: during a
// load, a cold start or a reconnect the composer stays and only Send waits.
const canWrite = owner || grantMode(grant, sessionId) === "full";
- const subagentControl = useObject(
- SubagentControl,
- canWrite && child && !oneShot ? { session: sessionId } : undefined,
- );
+ // Held with the view, not once the subagent list names the child: Archive works right after load.
+ const subagentControl = useObject(SubagentControl, canWrite ? { session: sessionId } : undefined);
+ // The bar's archived mark flips at the tap.
+ const [archived, archive] = useOptimisticCall(child?.archived ?? false, (_, next: boolean) => next, fail);
const composer = useComposerState();
const {
draft,
@@ -802,7 +839,7 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers
const fileCompletion = useFileCompletion(
composerRef,
setDraft,
- participant ? { session: sessionId, conversation: conversationId } : undefined,
+ canWrite ? { session: sessionId, conversation: conversationId } : undefined,
{ mention: canReply ? mentionName : undefined },
);
const metrics = useObject(SessionMetrics, { session: sessionId, viewer });
@@ -823,7 +860,8 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers
},
[setDraft],
);
- useRestoredDrafts(sessionId, conversationId, participant, restore);
+ // Who may write is known before the chat object is live (`canWrite`): drafts hold at once.
+ useRestoredDrafts(sessionId, conversationId, canWrite, restore);
const [pages, transcriptStore] = useTranscriptPages(cacheKey, chat);
// What this device kept starts the transcript; the live view is merged into it when it continues it.
useEffect(() => {
@@ -837,12 +875,7 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers
if (!owner || !liveView || !store?.value) return;
keep(cacheKey, () => ({ view: store.value!.view, sizes: [...keptSizes(cacheKey)] }));
}, [owner, liveView, pages, transcriptStore, cacheKey]);
- const perform = useCallback(
- (action: Promise | undefined) => {
- void action?.catch((reason: unknown) => setError(errorText(reason)));
- },
- [setError],
- );
+ const perform = useCallback((action: Promise | undefined) => void action?.catch(fail), [fail]);
// Plugins show their own display-only entry kinds (chat-mode's chat messages) as people's messages.
const entryKinds = useRegistry(TranscriptEntries);
const display = useCallback((kind: string, data: unknown) => entryKinds.get(kind)?.(data), [entryKinds]);
@@ -891,7 +924,7 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers
[pending, busy, started],
);
// The server's queued inputs, without those still shown as messages sent from here.
- const queued = useMemo(() => {
+ const serverQueued = useMemo(() => {
const sentIds = new Set(pending.map((message) => message.sent?.submissionId));
return transcript?.queued.filter((item) => !sentIds.has(item.id)) ?? [];
}, [transcript, pending]);
@@ -962,15 +995,17 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers
const start = started.get(message.id) ?? { after: newest.current, idle: !latest.current.busy };
started.set(message.id, start);
const { after } = start;
- const attachments: string[] = [];
- for (const file of message.files) {
- if (file.session !== session) {
- file.uploaded = undefined;
- file.session = session;
- }
- file.uploaded ??= await upload(session, file.file);
- attachments.push(file.uploaded);
- }
+ // Uploads run side by side; one kept from an earlier try of this send is reused.
+ const attachments = await Promise.all(
+ message.files.map(async (file) => {
+ if (file.session !== session) {
+ file.uploaded = undefined;
+ file.session = session;
+ }
+ file.uploaded ??= await upload(session, file.file);
+ return file.uploaded;
+ }),
+ );
// `/template args` and `/skill:name args` expand on the session worker first (10.8).
const text = message.text.startsWith("/")
? await expandComposerText(client, session, conversation, message.text)
@@ -1062,12 +1097,45 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers
const first = pages?.view.items.find((item) => item.kind === "entry");
const paged = (first?.kind === "entry" ? first.entry.id : Number.POSITIVE_INFINITY) < (view?.entries[0]?.id ?? 0);
const earlier = transcript?.earlier ? (pages?.loadingEarlier ? "loading" : "more") : paged ? "start" : undefined;
- const choice: ModelChoice | undefined = session
- ? {
- model: agentModel ?? session.model,
- thinkingLevel: transcript?.agent.thinkingLevel ?? (session.thinkingLevel as ModelChoice["thinkingLevel"]),
- }
- : undefined;
+ // The chip and the picker show a model change at once; the conversation's state replaces it with the answer.
+ const [choice, chooseModel] = useOptimisticCall(
+ session
+ ? {
+ model: agentModel ?? session.model,
+ thinkingLevel:
+ transcript?.agent.thinkingLevel ?? (session.thinkingLevel as ModelChoice["thinkingLevel"]),
+ }
+ : undefined,
+ (_, next) => next,
+ fail,
+ );
+ // A move is long work (a job): the top bar says "Moving…" from the tap until the job is done; a refused move
+ // (a busy session) shows as a failed operation in the sidebar.
+ const operations = useReadable(liveIn(sessions, "owner")?.operations);
+ const moveRunning =
+ operations?.some(
+ (operation) =>
+ operation.phase === "running" && operation.kind === "relocate" && operation.session === sessionId,
+ ) ?? false;
+ const [moving, relocate] = useOptimisticCall(moveRunning, () => true, fail);
+ // A clone or fork into this session whose files are still being copied: nothing is sent until its worker runs.
+ const copyTarget =
+ operations?.some((operation) => operation.phase === "running" && operation.target === sessionId) ?? false;
+ // A move of this session the worker refused (busy) or that failed: said in the toast once; the sidebar row keeps it
+ // until dismissed.
+ const failedMove = operations?.find(
+ (operation) => operation.phase === "failed" && operation.kind === "relocate" && operation.session === sessionId,
+ );
+ const failedMoveText = failedMove?.phase === "failed" ? `Move failed: ${failedMove.error}` : undefined;
+ useEffect(() => {
+ if (failedMoveText) toast(failedMoveText);
+ }, [failedMoveText, toast]);
+ // Removed queued inputs leave the queue at once.
+ const [queued, dequeue] = useOptimisticCall(
+ serverQueued,
+ (items, submissionId: number) => items.filter((item) => item.id !== submissionId),
+ fail,
+ );
const modelControl = owner ? (
) : (
@@ -1107,30 +1175,25 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers
Tools: {child.tools.join(", ") || "none"}
{!oneShot && viewLocation && ` · in ${locationLabel(viewLocation.env, viewLocation.cwd)}`}
{!oneShot && !child.location && " · follows the session"}
- {child.archived ? " · archived" : ""}
+ {archived ? " · archived" : ""}
- {canWrite && !oneShot && (
+ {canWrite && !oneShot && subagentControl && (
- perform(
- subagentControl?.archive(
- {
- conversation: child.conversationId,
- archived: !child.archived,
- },
+ archive(!archived, () =>
+ subagentControl.archive(
+ { conversation: child.conversationId, archived: !archived },
BACKGROUND_CONTEXT,
),
)
}
>
- {child.archived ? "Restore" : "Archive"}
+ {archived ? "Restore" : "Archive"}
)}
{canWrite && oneShot && busy && (
@@ -1179,13 +1242,14 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers
className="queue-edit"
title="Move back to editor"
onClick={() =>
- perform(
- writer
- ?.dequeue({ submissionId: item.id, restore: true }, BACKGROUND_CONTEXT)
- .then(({ text }) => {
- if (text) restore([text], true);
- }),
- )
+ writer &&
+ dequeue(item.id, async () => {
+ const { text } = await writer.dequeue(
+ { submissionId: item.id, restore: true },
+ BACKGROUND_CONTEXT,
+ );
+ if (text) restore([text], true);
+ })
}
>
{item.mode === "steer" ? "Steer" : "Queued"} {item.text}
@@ -1194,7 +1258,9 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers
type="button"
className="queue-remove"
aria-label="Remove from queue"
- onClick={() => perform(writer?.dequeue({ submissionId: item.id }, BACKGROUND_CONTEXT))}
+ onClick={() =>
+ writer && dequeue(item.id, () => writer.dequeue({ submissionId: item.id }, BACKGROUND_CONTEXT))
+ }
>
@@ -1221,7 +1287,7 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers
busy,
steer,
setSteer,
- disabled: false,
+ disabled: copyTarget,
onSend: send,
onStop: stop,
attaching,
@@ -1239,7 +1305,7 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers
);
return (
<>
-
+
{cwdSheet && owner && session && (
{
- perform(liveIn(sessions, "owner")?.relocate({ id: sessionId, env, cwd }, BACKGROUND_CONTEXT));
+ const catalog = liveIn(sessions, "owner");
+ if (catalog) relocate(true, () => catalog.relocate({ id: sessionId, env, cwd }, BACKGROUND_CONTEXT));
closeLayers();
}}
/>
@@ -1259,12 +1326,10 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers
perform(writer?.configure(choice, BACKGROUND_CONTEXT))}
- onDefault={(choice) =>
- perform(
- models?.setDefault({ ...choice.model, thinkingLevel: choice.thinkingLevel }, BACKGROUND_CONTEXT),
- )
- }
+ onChange={(next) => {
+ if (writer) chooseModel(next, () => writer.configure(next, BACKGROUND_CONTEXT));
+ }}
+ onDefault={setDefault}
onClose={closeLayers}
/>
)}
@@ -1298,7 +1363,7 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers
{owner ? (
void retry()}>
- Retry
+ {retrying ? "Retrying…" : "Retry"}
(listed ?? []).toSorted((a, b) => b.updatedAt - a.updatedAt), [listed]);
const { location: selectedLocation, setLocation, choice: selectedChoice, setChoice } = useNewSessionState();
const location =
@@ -1422,7 +1487,8 @@ function NewSession() {
whenBusy: steer ? "steer" : "followUp",
item: outgoingItem(id, text, files, OWNER_AUTHOR),
},
- { location, choice },
+ // The page chooses the session's ID: a retried creation answers the same session.
+ { session: crypto.randomUUID(), location, choice },
);
setDraft("");
setFiles([]);
@@ -1443,18 +1509,27 @@ function NewSession() {
return;
}
sessionStart.connect({
- create: async ({ location, choice }) =>
- (
+ create: async ({ session, location, choice }) => {
+ // The new session's worker starts beside the creation, not after it (its start waits for the row): held
+ // here and lingering for the session's view, which holds it next.
+ const chat = host.hold(Chat, { session, conversation: 1 }, BACKGROUND_CONTEXT);
+ try {
await sessions.create(
{
+ id: session,
env: location.env,
cwd: location.cwd,
model: choice.model,
thinkingLevel: choice.thinkingLevel,
},
BACKGROUND_CONTEXT,
- )
- ).id,
+ );
+ UI_LINGER.release(chat, Chat.name);
+ } catch (error) {
+ chat.release();
+ throw error;
+ }
+ },
// The server's own errors (a missing directory, an unknown model) refuse; a lost connection is tried again.
refused: (error) => !isChordError(error) || refused(error),
created: (session) => {
@@ -1467,7 +1542,7 @@ function NewSession() {
},
});
return () => sessionStart.connect(undefined);
- }, [connected, sessions, setDraft, setFiles, setError]);
+ }, [connected, sessions, host, setDraft, setFiles, setError]);
// Leaving the page loses a session not created yet: the browser asks first.
useEffect(() => {
if (!start) return;
@@ -1522,11 +1597,7 @@ function NewSession() {
resources={resources}
value={choice}
onChange={setChoice}
- onDefault={(choice) =>
- void models
- ?.setDefault({ ...choice.model, thinkingLevel: choice.thinkingLevel }, BACKGROUND_CONTEXT)
- .catch((error) => setError(errorText(error)))
- }
+ onDefault={setDefault}
onClose={closeLayers}
/>
)}
@@ -1597,7 +1668,14 @@ function Main() {
const [contents] = useState(() => new DockContents());
const composer = useRef(null);
const frame = useMemo(() => ({ view, dock, contents, composer }), [view, dock, contents]);
- const conversation = route.session && `${route.session}:${route.conversation ?? 1}`;
+ // A short link (`/s/`) opens the session's main conversation as last known here: no hold of conversation 1
+ // first and no redirect after the catalog arrives (the conversation view corrects a stale guess).
+ const shownConversation = route.session && route.conversation === 1 ? knownMain(route.session) : route.conversation;
+ useEffect(() => {
+ if (route.session && shownConversation !== route.conversation)
+ void nav.replace(conversationRoute(route, route.session, shownConversation), route);
+ }, [route, shownConversation, nav]);
+ const conversation = route.session && `${route.session}:${shownConversation}`;
return (
{ended ? (
@@ -1605,7 +1683,7 @@ function Main() {
) : route.session ? (
-
+
) : (
diff --git a/apps/p2/plugins/chat/worker.ts b/apps/p2/plugins/chat/worker.ts
index ffe06c3a5..d9ba68d3e 100644
--- a/apps/p2/plugins/chat/worker.ts
+++ b/apps/p2/plugins/chat/worker.ts
@@ -22,6 +22,7 @@ import {
authorIdOf,
authorOf,
authorSender,
+ type Context,
type Ctx,
canSend,
canView,
@@ -37,21 +38,12 @@ import {
isOwner,
type MessageData,
messageData,
- SHELL_ENTRY,
- SHELL_MAX_BYTES,
- SHELL_MAX_LINES,
- type ShellData,
senderText,
serveKeyed,
- shellContextText,
- shellTail,
- signalOf,
} from "@p2/app/v1";
-import { durableContext } from "@p2/app/v1/node";
import {
ContextTally,
ConversationConfigured,
- conversationEnvironment,
HarnessConfig,
harnessOf,
InputGates,
@@ -64,6 +56,7 @@ import { DraftStore } from "../drafts/contract.ts";
import { type Attachment, Attachments } from "../files/contract.ts";
import { WorkerModels } from "../models/contract.ts";
import { Sessions } from "../sessions/contract.ts";
+import { StatusReport } from "../status/contract.ts";
import {
Chat,
ChatCosts,
@@ -71,11 +64,9 @@ import {
type EntriesPage,
type ModelMeta,
SendRouters,
- SHELL_BUSY,
- type ShellState,
- ShellStateSchema,
StatsSchema,
} from "./contract.ts";
+import { ShellCommands, serveShellCommands } from "./shell.ts";
/** Who wrote a queued input and what they typed, from its entry data; tool-originated input has none. */
function restoredInput(item: InboxItem): { author: Author; text: string } | undefined {
@@ -89,14 +80,11 @@ const MAX_INLINE_IMAGE = 5 * 1024 * 1024;
/** A `Chat.entries` page ends once its entries reach about this many bytes of JSON. */
const ENTRIES_PAGE_BYTES = 1_000_000;
-/** How often the live output is published at most (the bash tool's sampling, 100 KiB/s). */
-const SHELL_SAMPLE_MS = 200;
-
-/** The conversation's live numbers and the running user shell command (in memory). */
-const Live = defineTypedState("p2.conversation", {
- schema: obj({ stats: StatsSchema, shell: ShellStateSchema }),
+/** The conversation's active context numbers (in memory), computed from its transcript. */
+const Live = defineTypedState("p2.conversation.stats", {
+ schema: obj({ stats: StatsSchema }),
version: 1,
- initial: () => ({ stats: null, shell: null }),
+ initial: () => ({ stats: null }),
});
/**
@@ -111,67 +99,42 @@ async function content(
ctx: Ctx,
maxImages = Number.POSITIVE_INFINITY,
): Promise<(ImageContent | { type: "text"; text: string })[]> {
- {
- if (!files.length) return [{ type: "text", text }];
- const { model: ref } = await conversation.agent(ctx);
- const model = ref ? models?.getModel(ref.provider, ref.modelId) : undefined;
- const images: ImageContent[] = [];
- const eligible = files.filter(
- (file) => file.mimeType && model?.input.includes("image") && file.size <= MAX_INLINE_IMAGE,
- );
- // With a cap, the newest images (the last ones) are inlined; the rest are only listed.
- for (const file of maxImages > 0 ? eligible.slice(-maxImages) : []) {
- if (!file.mimeType) continue;
- images.push({
- type: "image",
- mimeType: file.mimeType,
- data: (await readFile(file.localPath ?? file.path)).toString("base64"),
- });
- }
- const list = files.map((file) => `- ${file.path} (${file.name}, ${file.size} bytes)`).join("\n");
- const note = `\nThe user attached these files:\n${list}\n `;
- return [{ type: "text", text: text.trim() ? `${text}\n\n${note}` : note }, ...images];
+ if (!files.length) return [{ type: "text", text }];
+ const { model: ref } = await conversation.agent(ctx);
+ const model = ref ? models?.getModel(ref.provider, ref.modelId) : undefined;
+ const images: ImageContent[] = [];
+ const eligible = files.filter(
+ (file) => file.mimeType && model?.input.includes("image") && file.size <= MAX_INLINE_IMAGE,
+ );
+ // With a cap, the newest images (the last ones) are inlined; the rest are only listed.
+ for (const file of maxImages > 0 ? eligible.slice(-maxImages) : []) {
+ if (!file.mimeType) continue;
+ images.push({
+ type: "image",
+ mimeType: file.mimeType,
+ data: (await readFile(file.localPath ?? file.path)).toString("base64"),
+ });
}
+ const list = files.map((file) => `- ${file.path} (${file.name}, ${file.size} bytes)`).join("\n");
+ const note = `\nThe user attached these files:\n${list}\n `;
+ return [{ type: "text", text: text.trim() ? `${text}\n\n${note}` : note }, ...images];
}
export default defineFacet((host) => {
serveKeyed(host, SendRouters);
+ serveShellCommands(host);
host.serve(
Conversation,
({ conversation }, self) => {
const id = conversation as ConversationId;
const harness = harnessOf(self);
- const worker = workerOf(self);
const config = self.registry(HarnessConfig);
const gates = self.registry(InputGates);
const configured = self.registry(ConversationConfigured);
+ // Methods that start or end work push the worker's status before they answer (the sidebar's working dot).
+ const status = self.get(StatusReport, {});
const models = () => config.value.get("default")?.models();
const live = self.state(Live);
- const stats = {
- get value() {
- return live.value.stats;
- },
- };
- /** The running command as last set: a change reaches the state asynchronously, the checks need it now. */
- let shellNow: ShellState = null;
- const shell = {
- get value() {
- return shellNow;
- },
- };
- const setStats = (next: Extract, ctx: Ctx) =>
- live.change((draft) => {
- draft.stats = next;
- }, ctx);
- /** Awaited where `ctx` (a root's) seals right after: a change still pending then would be dropped. */
- const setShell = async (next: ShellState, ctx: Ctx): Promise => {
- shellNow = next;
- await live
- .change((draft) => {
- draft.shell = next;
- }, ctx)
- .catch(() => {});
- };
/** The view's model from the catalog: name, window and levels, never provider configuration. */
const modelMeta = (view: ConversationView | null): ModelMeta | null => {
const ref = (view?.docs["pi.agent"] as AgentState | undefined)?.model;
@@ -206,262 +169,77 @@ export default defineFacet((host) => {
/**
* The active context's numbers follow the entries the window appends (a scan of the new ones). A whole context
* is read only when the active range changes otherwise (`ContextTally.advance`): at the start, after a
- * compaction, reset or edit, or when the window skipped entries. Views arriving while it is read are taken from
- * the newest view after it.
+ * compaction, reset or edit, or when the window skipped entries. Views arriving while it is read come folded
+ * into the routine's next call.
*/
const tally = new ContextTally();
- let reading = true;
- let wake: (() => void) | undefined;
- const publish = async (ctx: Ctx) => {
+ let counted = false;
+ self.on(view, async ({ value }, ctx) => {
+ if (!value) return;
+ if (!counted || !tally.advance(value)) {
+ tally.reset(await conversationResource.value.conversation.context(ctx));
+ counted = true;
+ const newest = view.value;
+ if (newest) tally.advance(newest);
+ }
const next = tally.stats;
- const current = stats.value;
+ const current = live.value.stats;
if (
current?.contextTokens !== next.contextTokens ||
current.latestAssistantUsage !== next.latestAssistantUsage
)
- await setStats(next, ctx);
- };
- self.on(view, async ({ value }, ctx) => {
- if (!value || reading) return;
- if (tally.advance(value)) await publish(ctx);
- else {
- reading = true;
- wake?.();
- }
- });
- self.onLive(async (ctx) => {
- const signal = signalOf(ctx);
- while (!signal.aborted) {
- if (!reading)
- await new Promise((resolve) => {
- const abort = () => resolve();
- wake = () => {
- signal.removeEventListener("abort", abort);
- resolve();
- };
- signal.addEventListener("abort", abort, { once: true });
- });
- wake = undefined;
- if (signal.aborted) return;
- tally.reset(await conversationResource.value.conversation.context(ctx));
- const current = view.value;
- reading = current !== null && !tally.advance(current);
- await publish(ctx);
- }
+ await live.change((draft) => {
+ draft.stats = next;
+ }, ctx);
});
const target = (): { harness: DurableHarness; conversation: DurableConversation } =>
conversationResource.value;
/** The queued inputs (not writes) by submission order. */
- const queued = async (context: ReturnType) => {
+ const queued = async (context: Context) => {
const { harness: durable, conversation: current } = target();
const inbox = await durable.snapshot(InboxDoc, current.id, context);
return (inbox?.items ?? []).filter((item) => item.mode !== "write").toSorted((a, b) => a.id - b.id);
};
- /**
- * The running user shell command (one per conversation) and the commands already admitted per author request,
- * so a retried call runs once. Closing the object (no viewer for its linger time, a reload) cancels it.
- */
- let running: { id: string; abort: AbortController } | undefined;
- const admitted = new Map();
- self.open(
- async () => ({}),
- () => running?.abort.abort(),
- );
- /** Runs `command` and places its entry; the live state follows the output (sampled). */
- const runShell = async (
- shellId: string,
- input: { command: string; includeInContext: boolean; author: Author },
- abort: AbortController,
- ctx: Ctx,
- ): Promise => {
- let live: Extract = {
- id: shellId,
- command: input.command,
- author: input.author,
- includeInContext: input.includeInContext,
- output: "",
- truncated: false,
- running: true,
- cancelled: false,
- };
- await setShell(live, ctx);
- let output = "";
- let truncated = false;
- let timer: ReturnType | undefined;
- const publish = () => {
- timer = undefined;
- if (shell.value?.id !== shellId) return;
- live = { ...live, output, truncated };
- void setShell(live, ctx);
- };
- let ended: Pick = {};
- try {
- // Where the conversation works: a subagent's own location may be on another machine than the session.
- const { location, env } = await conversationEnvironment(harness().environments, id, durableContext(ctx));
- const result = await env.exec(
- input.command,
- {
- cwd: location.cwd,
- inheritEnv: true,
- spill: { afterBytes: SHELL_MAX_BYTES, afterLines: SHELL_MAX_LINES },
- window: {
- maxBytes: SHELL_MAX_BYTES,
- maxLines: SHELL_MAX_LINES,
- minIntervalMs: SHELL_SAMPLE_MS,
- bytesPerSecond: 100 * 1024,
- },
- onOutput: (text, _context, info) => {
- // The environment omitted output outside the kept tail: what follows replaces it.
- if (info.skipped) {
- output = "";
- truncated = true;
- }
- const tail = shellTail(output + text);
- output = tail.text;
- truncated ||= tail.truncated;
- timer ??= setTimeout(publish, SHELL_SAMPLE_MS);
- },
- },
- durableContext(abort.signal),
- );
- if (result.ok)
- ended = {
- exitCode: result.value.exitCode,
- ...(result.value.spillPath ? { fullOutputPath: result.value.spillPath } : {}),
- };
- else if (result.error.code !== "aborted")
- ended = {
- error: result.error.code === "timeout" ? "Command timed out" : result.error.message,
- ...(result.error.spillPath ? { fullOutputPath: result.error.spillPath } : {}),
- };
- } catch (error) {
- ended = { error: error instanceof Error ? error.message : String(error) };
- } finally {
- clearTimeout(timer);
- }
- const cancelled = abort.signal.aborted;
- // A command's last newline ends its output; it is not an empty last line.
- output = output.replace(/\r?\n$/, "");
- const data: ShellData = {
- author: input.author,
- command: input.command,
- output,
- cancelled,
- truncated,
- includeInContext: input.includeInContext,
- ...ended,
- };
- live = {
- ...live,
- output,
- truncated,
- running: false,
- cancelled,
- ...(ended.exitCode === undefined ? {} : { exitCode: ended.exitCode }),
- ...(ended.error === undefined ? {} : { error: ended.error }),
- };
- await setShell(live, ctx);
- try {
- // A write never starts a run; while the agent works it is placed at the run's next boundary.
- const submission = await target().conversation.submit(
- {
- type: "write",
- entry: {
- kind: SHELL_ENTRY,
- data: data as unknown as JsonValue,
- ...(input.includeInContext
- ? {
- model: [
- {
- role: "user" as const,
- content: [
- { type: "text" as const, text: senderText(authorSender(input.author)) },
- { type: "text" as const, text: shellContextText(data) },
- ],
- timestamp: Date.now(),
- },
- ],
- }
- : {}),
- },
- requestId: `${input.author.id}:shell:${shellId}`,
- },
- durableContext(ctx),
- );
- await submission.wait(durableContext(ctx));
- } finally {
- // The entry shows the command from now on (or the write failed: nothing to keep showing).
- if (shell.value?.id === shellId) await setShell(null, ctx);
- }
- };
return {
view,
stats: deriveTyped(self, [live], ([value]) => value.stats),
model: modelState,
- shell: deriveTyped(self, [live], ([value]) => value.shell),
costs: async (ctx) =>
- responseCosts((await target().conversation.context(durableContext(ctx))).entries).map(
- ({ cacheHit, ...cost }) => (cacheHit === undefined ? cost : { ...cost, cacheHit }),
+ responseCosts((await target().conversation.context(ctx)).entries).map(({ cacheHit, ...cost }) =>
+ cacheHit === undefined ? cost : { ...cost, cacheHit },
),
- runShell: async ({ command, includeInContext, requestId, author }, ctx) => {
- const key = `${author.id}:${requestId}`;
- const known = admitted.get(key);
- if (known) return { shellId: known };
- if (shell.value?.running) throw new Error(SHELL_BUSY);
- for (const gate of gates.value.values()) await gate(ctx);
- const shellId = crypto.randomUUID();
- admitted.set(key, shellId);
- if (admitted.size > 100) admitted.delete(admitted.keys().next().value!);
- const abort = new AbortController();
- running = { id: shellId, abort };
- // An active call while it runs: the worker stays up, and a move or clone waits for it.
- // The method returns now: the command runs under its own root context, not the call's.
- void worker()
- .liveness.call(() =>
- worker().root((root) => runShell(shellId, { command, includeInContext, author }, abort, root)),
- )
- .catch((error: unknown) => console.error("shell:", error))
- .finally(() => {
- if (running?.id === shellId) running = undefined;
- });
- return { shellId };
- },
- abortShell: async ({ shellId }) => {
- if (running?.id !== shellId || running.abort.signal.aborted) return { aborted: false };
- running.abort.abort();
- return { aborted: true };
- },
compact: async ({ instructions }, ctx) => {
- const taskId = await target().conversation.compact(
- instructions?.trim() || undefined,
- durableContext(ctx),
- );
+ const taskId = await target().conversation.compact(instructions?.trim() || undefined, ctx);
+ await status.push(ctx);
return { taskId };
},
reset: async ({ handoff }, ctx) => {
- await target().conversation.reset(handoff?.trim() || undefined, durableContext(ctx));
+ await target().conversation.reset(handoff?.trim() || undefined, ctx);
return null;
},
abortCompaction: async (ctx) => {
- const context = durableContext(ctx);
const { harness: durable } = target();
// The live compaction tasks of this conversation, as its view shows them.
const running = view.value?.docs["pi.live"] as { compactions?: { taskId: number }[] } | undefined;
let aborted = 0;
for (const { taskId } of running?.compactions ?? [])
- if ((await durable.abortTask(taskId as TaskId, context)) === "marked") aborted++;
+ if ((await durable.abortTask(taskId as TaskId, ctx)) === "marked") {
+ // The indicator goes with the task's terminal commit (its abort hook removes its status).
+ await durable.waitForTask(taskId as TaskId, ctx);
+ aborted++;
+ }
+ await status.push(ctx);
return { aborted };
},
dequeueOwn: async ({ authorId }, ctx) => {
- const context = durableContext(ctx);
const { harness: durable, conversation: current } = target();
// Selected by the author stamped into each input's entry, never by client-supplied IDs.
const restored: { author: Author; text: string }[] = [];
let skipped = 0;
- for (const item of await queued(context)) {
+ for (const item of await queued(ctx)) {
const input = restoredInput(item);
if (input?.author.id !== authorId) continue;
- if ((await durable.abortSubmission(item.id as SubmissionId, context, current.id)) === "aborted")
+ if ((await durable.abortSubmission(item.id as SubmissionId, ctx, current.id)) === "aborted")
restored.push(input);
else skipped++;
}
@@ -487,52 +265,50 @@ export default defineFacet((host) => {
requestId: `${author.id}:${requestId}`,
whenBusy: whenBusy ?? "steer",
},
- durableContext(ctx),
+ ctx,
);
+ await status.push(ctx);
return { submissionId: submission.id };
},
write: async ({ kind, data, requestId, author }, ctx) => {
for (const gate of gates.value.values()) await gate(ctx);
const submission = await target().conversation.submit(
{ type: "write", entry: { kind, data: data as JsonValue }, requestId: `${author.id}:${requestId}` },
- durableContext(ctx),
+ ctx,
);
return { submissionId: submission.id };
},
stop: async (ctx) => {
- const context = durableContext(ctx);
const { harness: durable, conversation: current } = target();
// Conversation.abort() discards queued inputs. Withdraw them first, one by one, so each author gets
// their text back; already placed ones are skipped.
const restored: { author: Author; text: string }[] = [];
- const inbox = await durable.snapshot(InboxDoc, current.id, context);
+ const inbox = await durable.snapshot(InboxDoc, current.id, ctx);
for (const item of inbox?.items ?? []) {
if (item.mode === "write") continue;
- if ((await durable.abortSubmission(item.id as SubmissionId, context, current.id)) !== "aborted")
- continue;
+ if ((await durable.abortSubmission(item.id as SubmissionId, ctx, current.id)) !== "aborted") continue;
const input = restoredInput(item);
if (input) restored.push(input);
}
- await current.abort(context, { background: false });
+ await current.abort(ctx, { background: false });
+ await status.push(ctx);
return { restored };
},
dequeue: async ({ submissionId, restore }, ctx) => {
- const context = durableContext(ctx);
const { harness: durable, conversation: current } = target();
// Removing (no restore) discards the input: its text goes to nobody.
const item = restore
- ? (await durable.snapshot(InboxDoc, current.id, context))?.items.find(
+ ? (await durable.snapshot(InboxDoc, current.id, ctx))?.items.find(
(entry) => entry.id === submissionId,
)
: undefined;
- const result = await durable.abortSubmission(submissionId as SubmissionId, context, current.id);
+ const result = await durable.abortSubmission(submissionId as SubmissionId, ctx, current.id);
if (result === "not_found") throw new Error("Unknown queued message");
// If it raced into the transcript, it is not returned to the editor (no duplicate).
const input = result === "aborted" && item ? restoredInput(item) : undefined;
return { restored: input ? [input] : [] };
},
entries: async ({ limit, ...query }, ctx) => {
- const context = durableContext(ctx);
const { conversation: current } = target();
const page = await current.entries(
{
@@ -542,7 +318,7 @@ export default defineFacet((host) => {
},
limit,
undefined,
- context,
+ ctx,
);
// Cut at about a megabyte (images make entries large); the loader asks again from where it ends.
const items: EntryRecord[] = [];
@@ -562,7 +338,7 @@ export default defineFacet((host) => {
const current = target().conversation;
await current.configure(
{ ...(model ? { model } : {}), ...(thinkingLevel ? { thinkingLevel: thinkingLevel as never } : {}) },
- durableContext(ctx),
+ ctx,
);
for (const after of configured.value.values()) await after(current, ctx);
return null;
@@ -582,23 +358,22 @@ export default defineFacet((host) => {
const settings = self.get(WorkerModels, {});
/** The author an entry records, with the owner's name as settings.json says now. */
const author = (): Author => authorOf(principal, settings.settings.value.ownerName ?? DEFAULT_OWNER_NAME);
- const worker = workerOf(self);
- const harness = harnessOf(self);
- // Each live Chat object is a viewer: the worker stays up while someone looks.
- self.open(
- async () => worker().liveness.add("viewers", 1),
- () => worker().liveness.add("viewers", -1),
- );
+ const shells = self.get(ShellCommands, {});
+ const liveShell = deriveTyped(self, [shells.shells], ([value]) => value[conversation] ?? null);
const entries: typeof chat.entries = (query, ctx) => chat.entries(query, ctx);
if (!canSend(principal, session))
return {
variant: "viewer",
- members: { view: chat.view, stats: chat.stats, model: chat.model, liveShell: chat.shell, entries },
+ members: { view: chat.view, stats: chat.stats, model: chat.model, liveShell, entries },
};
+ const worker = workerOf(self);
+ const harness = harnessOf(self);
+ /** A participant's call: refused while the session moves, which waits for running calls (`Liveness.call`). */
const call = (fn: () => Promise): Promise => worker().liveness.call(fn);
/** Refuses writes a plugin does not allow here (a one-shot agent's conversation); Stop is not one of them. */
const readOnlyConversations = self.registry(ReadOnlyConversations);
const routers = self.registry(SendRouters);
+ const gates = self.registry(InputGates);
const writable = async (ctx: Ctx): Promise => {
for (const readOnly of readOnlyConversations.value.values()) {
const reason = await readOnly(conversation, ctx);
@@ -617,7 +392,7 @@ export default defineFacet((host) => {
view: chat.view,
stats: chat.stats,
model: chat.model,
- liveShell: chat.shell,
+ liveShell,
entries,
compact: (args, ctx) =>
@@ -633,20 +408,23 @@ export default defineFacet((host) => {
await writable(ctx);
return chat.reset(args, ctx);
}),
- shell: (input, ctx) =>
+ shell: ({ command, includeInContext, requestId }, ctx) =>
call(async () => {
// Anyone who may send may run commands, as tools run them for them (full guests included).
await writable(ctx);
- if (!input.command.trim()) throw new Error("Invalid command");
- return chat.runShell({ ...input, author: author() }, ctx);
+ if (!command.trim()) throw new Error("Invalid command");
+ for (const gate of gates.value.values()) await gate(ctx);
+ return shells.start({ conversation, command, includeInContext, requestId, author: author() }, ctx);
}),
abortShell: ({ shellId }, ctx) =>
call(async () => {
- // Its author or the owner; the command's author comes from the conversation's live state.
- const live = chat.shell.value;
- if (live?.id === shellId && principal.kind !== "owner" && live.author.id !== authorId)
+ // Its author or the owner; the command's author comes from its running state.
+ const live = liveShell.value;
+ // Only this conversation's command: `shellId` is its caller's request ID, not secret.
+ if (live?.id !== shellId) return { aborted: false };
+ if (principal.kind !== "owner" && live.author.id !== authorId)
throw forbidden("only its author or the owner cancels a command");
- return chat.abortShell({ shellId }, ctx);
+ return shells.abort({ shellId }, ctx);
}),
dequeueOwn: (ctx) =>
call(async () => {
@@ -695,7 +473,7 @@ export default defineFacet((host) => {
// field alone updates it (Pi Mobile's configure).
if (
(change.model || change.thinkingLevel) &&
- conversation === (await mainConversation(harness().durable, durableContext(ctx)))
+ conversation === (await mainConversation(harness().durable, ctx))
) {
await expectVariant(await ctx.get(Sessions, {}), "worker").configureSelf(
{
diff --git a/apps/p2/plugins/codemode/calls.ts b/apps/p2/plugins/codemode/calls.ts
index dcc70e5d3..53561944d 100644
--- a/apps/p2/plugins/codemode/calls.ts
+++ b/apps/p2/plugins/codemode/calls.ts
@@ -3,12 +3,12 @@
import {
type Harness as DurableHarness,
LiveDoc,
+ type LiveState,
type TaskId,
type ToolExecutionApi,
type ToolExecutionResult,
} from "@earendil-works/pi-durable";
import type { Context } from "@p2/app/v1";
-import { BACKGROUND_CONTEXT } from "@p2/app/v1";
/** What a call removed mid-script reports, as its output, before Durable settles it aborted. */
export const REMOVED_MESSAGE = "Code mode was removed while this script ran.";
@@ -55,8 +55,7 @@ export class RunningCalls {
}
/** Ends every running call with an error result; resolves once each is terminal. */
- async cancel(harness: DurableHarness): Promise {
- const context = BACKGROUND_CONTEXT;
+ async cancel(harness: DurableHarness, context: Context): Promise {
await Promise.all(
[...this.#calls.values()].map(async (api) => {
try {
@@ -74,11 +73,28 @@ export class RunningCalls {
/** Resolves once the call's slot holds the removal message (the output is committed with a throttle), or after a bound. */
async function reported(harness: DurableHarness, api: ToolExecutionApi, context: Context): Promise {
- const deadline = Date.now() + REPORT_MS;
- while (Date.now() < deadline) {
- const live = await harness.snapshot(LiveDoc, api.conversationId, context);
+ const done = (live: Readonly | undefined): boolean => {
const slot = live?.tools?.find((tool) => tool.taskId === api.taskId);
- if (!slot || slot.status === "done" || slot.output?.includes(REMOVED_MESSAGE)) return;
- await new Promise((resolve) => setTimeout(resolve, 25));
+ return !slot || slot.status === "done" || slot.output?.includes(REMOVED_MESSAGE) === true;
+ };
+ const shown = Promise.withResolvers();
+ const observation = await harness.observeDoc(
+ LiveDoc,
+ api.conversationId,
+ {
+ advance: (call) => {
+ if (call.kind === "value" && done(call.value)) shown.resolve();
+ },
+ end: () => shown.resolve(),
+ },
+ context,
+ );
+ if (observation === undefined) return;
+ const timer = setTimeout(shown.resolve, REPORT_MS);
+ try {
+ if (!done(observation.value)) await shown.promise;
+ } finally {
+ clearTimeout(timer);
+ observation.detach();
}
}
diff --git a/apps/p2/plugins/codemode/worker.ts b/apps/p2/plugins/codemode/worker.ts
index aace71c7f..ac855e9fd 100644
--- a/apps/p2/plugins/codemode/worker.ts
+++ b/apps/p2/plugins/codemode/worker.ts
@@ -27,8 +27,8 @@ export default defineFacet((host) => {
);
self.open(
async () => calls,
- async (running, reason) => {
- if (reason.kind === "end" && !worker().liveness.stopping) await running.cancel(harness().durable);
+ async (running, reason, context) => {
+ if (reason.kind === "end" && !worker().liveness.stopping) await running.cancel(harness().durable, context);
},
);
// The tool reads the sandbox when a script starts: a running script keeps the sandbox it started on.
diff --git a/apps/p2/plugins/conversations/server.ts b/apps/p2/plugins/conversations/server.ts
index f9b103439..cbd888f0b 100644
--- a/apps/p2/plugins/conversations/server.ts
+++ b/apps/p2/plugins/conversations/server.ts
@@ -1,10 +1,11 @@
import { awaitWithContext, defineFacet, forbidden, isChordError, isWorker, signalOf } from "@p2/app/v1";
-import { durableContext } from "@p2/app/v1/node";
import { Hosts } from "@p2/app/v1/server";
import { Catalog } from "../sessions/contract.ts";
import { StatusStore } from "../status/contract.ts";
import { ConversationBroker, Conversations, PeerConversation } from "./contract.ts";
+// The server's side of peer conversations: the broker between session workers (a local object, so its references act
+// as the server, whom `PeerConversation` serves), and the workers' view of it, fenced by worker generation.
export default defineFacet((host) => {
host.serve(ConversationBroker, (_params, self) => {
const catalog = self.get(Catalog, {});
@@ -14,13 +15,13 @@ export default defineFacet((host) => {
const sender = await catalog.get({ id: source }, ctx);
if (sender.archivedAt !== null) throw forbidden("The sending session is archived");
if (query.action === "list") {
- const sessions = (catalog.sessions.value ?? []).filter((session) => session.archivedAt === null);
+ const sessions = catalog.sessions.value.filter((session) => session.archivedAt === null);
return {
action: "list",
self: source,
truncated: sessions.length > 200,
peers: sessions.slice(0, 200).map((session) => {
- const status = statuses.statuses.value?.[session.id];
+ const status = statuses.statuses.value[session.id];
return {
id: session.id,
title: session.title,
@@ -43,10 +44,7 @@ export default defineFacet((host) => {
(await catalog.get({ id: recipient.id }, ctx)).archivedAt !== null;
// Each attempt holds the recipient's worker afresh, starting it if needed, as a browser opening it does.
const deliver = async () => {
- const peer = await awaitWithContext(
- ctx.get(PeerConversation, { session: recipient.id }),
- durableContext(ctx),
- );
+ const peer = await awaitWithContext(ctx.get(PeerConversation, { session: recipient.id }), ctx);
// Startup can yield to archive/restore. Recheck both sessions before admitting peer work.
if (await archived()) throw forbidden("Conversation is archived");
if (query.action === "answer") return { action: "answer" as const, answer: await peer.answer(ctx) };
@@ -91,7 +89,7 @@ export default defineFacet((host) => {
variant: "worker",
members: {
query: async ({ requestId, origin, query }, ctx) => {
- const current = hosts.workers.value?.[principal.session];
+ const current = hosts.workers.value[principal.session];
if (!current?.running || current.generation !== principal.generation) throw forbidden("stale worker");
return broker.query({ source: principal.session, requestId, ...(origin ? { origin } : {}), query }, ctx);
},
diff --git a/apps/p2/plugins/conversations/web.tsx b/apps/p2/plugins/conversations/web.tsx
index 7629a61ac..ae8657bec 100644
--- a/apps/p2/plugins/conversations/web.tsx
+++ b/apps/p2/plugins/conversations/web.tsx
@@ -7,7 +7,6 @@ import {
useConnected,
useObject,
useReadable,
- useStatus,
} from "@p2/app/v1/web";
import type { ReactNode } from "react";
import { Sessions } from "../sessions/contract.ts";
@@ -15,10 +14,10 @@ import { Sessions } from "../sessions/contract.ts";
function Attribution({ children }: { children?: ReactNode }) {
const sessions = useObject(Sessions, {});
const listed = useReadable(liveIn(sessions, "owner", "guest")?.sessions) ?? [];
- const owner = useStatus(sessions) === "live" ? liveIn(sessions, "owner") : undefined;
+ const owner = liveIn(sessions, "owner");
// As Pi Mobile: peer labels link only while connected; errors go to the app's toast.
const connected = useConnected();
- const { setError } = useComposerState();
+ const { setError, fail } = useComposerState();
const open = (session: string): void => {
const current = listed.find((entry) => entry.id === session);
if (!current) {
@@ -26,11 +25,12 @@ function Attribution({ children }: { children?: ReactNode }) {
return;
}
if (current.archivedAt === null) navigate({ session });
+ // An archived source opens once restored (the view refuses an archived session).
else
void owner
?.restore({ id: session }, BACKGROUND_CONTEXT)
.then(() => navigate({ session }))
- .catch((error: unknown) => setError(error instanceof Error ? error.message : String(error)));
+ .catch(fail);
};
return (
diff --git a/apps/p2/plugins/conversations/worker.ts b/apps/p2/plugins/conversations/worker.ts
index 466a22ab7..4c9a66acf 100644
--- a/apps/p2/plugins/conversations/worker.ts
+++ b/apps/p2/plugins/conversations/worker.ts
@@ -11,7 +11,6 @@ import {
type PeerMessage,
senderText,
} from "@p2/app/v1";
-import { durableContext } from "@p2/app/v1/node";
import {
defineP2Tool,
Extensions,
@@ -24,6 +23,7 @@ import {
type WorkerParts,
workerOf,
} from "@p2/app/v1/worker";
+import { StatusReport } from "../status/contract.ts";
import { Conversations, PeerConversation, type Query } from "./contract.ts";
const ConversationTools = local("p2.conversation-tools", { version: 1, params: {}, members: {} });
@@ -84,16 +84,19 @@ const conversationsExtension = (worker: () => WorkerParts) => {
try {
// The recipient's entry records it: its turn may act for the owner too.
const origin = await originOf(api, context);
- const result = await worker().root(async (ctx) =>
- expectVariant(await ctx.get(Conversations, {}), "worker").query(
- {
- conversation: 1,
- requestId: `${api.taskId}:${api.callId}`,
- ...(origin ? { origin } : {}),
- query,
- },
- ctx,
- ),
+ // A root of the worker's own under the tool's context (its cancellation, its trace).
+ const result = await worker().root(
+ async (ctx) =>
+ expectVariant(await ctx.get(Conversations, {}), "worker").query(
+ {
+ conversation: 1,
+ requestId: `${api.taskId}:${api.callId}`,
+ ...(origin ? { origin } : {}),
+ query,
+ },
+ ctx,
+ ),
+ context,
);
switch (result.action) {
case "list":
@@ -142,6 +145,7 @@ export default defineFacet((host) => {
const harness = harnessOf(self);
const worker = workerOf(self);
const gates = self.registry(InputGates);
+ const status = self.get(StatusReport, {});
return {
variant: "broker",
members: {
@@ -149,8 +153,7 @@ export default defineFacet((host) => {
worker().liveness.call(async () => {
for (const gate of gates.value.values()) await gate(ctx);
const durable = harness().durable;
- const context = durableContext(ctx);
- const main = await durable.conversation(await mainConversation(durable, context), context);
+ const main = await durable.conversation(await mainConversation(durable, ctx), ctx);
if (!main) throw new Error("No main conversation");
const sent = await main.submit(
{
@@ -170,22 +173,23 @@ export default defineFacet((host) => {
requestId: `conversation:${source}:${requestId}`,
whenBusy: followUp ? "followUp" : "steer",
},
- context,
+ ctx,
);
+ // The recipient's working dot is current when the sender's tool answers.
+ await status.push(ctx);
return { submissionId: sent.id };
}),
answer: (ctx) =>
worker().liveness.call(async () => {
const durable = harness().durable;
- const context = durableContext(ctx);
- const main = await durable.conversation(await mainConversation(durable, context), context);
+ const main = await durable.conversation(await mainConversation(durable, ctx), ctx);
if (!main) throw new Error("No main conversation");
let cursor: Cursor | undefined;
let text: string | undefined;
let truncated = false;
let limited = false;
for (let scanned = 0; scanned < 2000; ) {
- const page = await main.entries({}, 100, cursor, context);
+ const page = await main.entries({}, 100, cursor, ctx);
for (const entry of page.items) {
scanned++;
const message = AssistantEntry.is(entry)
@@ -204,7 +208,7 @@ export default defineFacet((host) => {
cursor = page.next;
limited = scanned >= 2000;
}
- const busy = (await durable.snapshot(LiveDoc, main.id, context))?.run !== undefined;
+ const busy = (await durable.snapshot(LiveDoc, main.id, ctx))?.run !== undefined;
return { ...(text === undefined ? {} : { text }), busy, truncated, limited };
}),
},
diff --git a/apps/p2/plugins/devices/server.ts b/apps/p2/plugins/devices/server.ts
index 6ac1a54c1..ee85492b4 100644
--- a/apps/p2/plugins/devices/server.ts
+++ b/apps/p2/plugins/devices/server.ts
@@ -1,5 +1,14 @@
import type { IncomingMessage, ServerResponse } from "node:http";
-import { type Ctx, defineFacet, forbidden, isOwner, nativeOf, type P2Principal, serveNative } from "@p2/app/v1";
+import {
+ type Ctx,
+ constant,
+ defineFacet,
+ forbidden,
+ isOwner,
+ nativeOf,
+ type P2Principal,
+ serveNative,
+} from "@p2/app/v1";
import {
Authenticators,
fromOrigin,
@@ -146,7 +155,7 @@ export default defineFacet((host) => {
return {
variant: "owner",
members: {
- canMint: self.derive([], () => local),
+ canMint: constant(local),
token: async () => {
if (!local) throw forbidden("Generate owner links on the host with npm run owner-link");
return { token: access().access.issueToken() };
diff --git a/apps/p2/plugins/drafts/worker.ts b/apps/p2/plugins/drafts/worker.ts
index e749a84ba..7a7e202a9 100644
--- a/apps/p2/plugins/drafts/worker.ts
+++ b/apps/p2/plugins/drafts/worker.ts
@@ -29,14 +29,17 @@ export default defineFacet((host) => {
claim: async ({ conversation, authorId, ids }, ctx) => {
const key = draftKey(conversation, authorId);
const wanted = new Set(ids);
- const claimed = (store.value.drafts[key] ?? []).filter((item) => wanted.has(item.id));
- if (!claimed.length) return { texts: [] };
- await store.change((draft) => {
- const rest = (draft.drafts[key] ?? []).filter((item) => !wanted.has(item.id));
+ // Read and removed in one change: two tabs claiming at once get each draft once.
+ const texts = await store.change((draft) => {
+ const items = draft.drafts[key] ?? [];
+ const claimed = items.filter((item) => wanted.has(item.id)).map((item) => item.text);
+ if (!claimed.length) return [];
+ const rest = items.filter((item) => !wanted.has(item.id));
if (rest.length) draft.drafts[key] = rest;
else delete draft.drafts[key];
+ return claimed;
}, ctx);
- return { texts: claimed.map((item) => item.text) };
+ return { texts };
},
};
});
diff --git a/apps/p2/plugins/envs/EnvsPanel.tsx b/apps/p2/plugins/envs/EnvsPanel.tsx
index 9182848b6..10f88363e 100644
--- a/apps/p2/plugins/envs/EnvsPanel.tsx
+++ b/apps/p2/plugins/envs/EnvsPanel.tsx
@@ -3,11 +3,13 @@ import { BACKGROUND_CONTEXT } from "@p2/app/v1";
import {
ChevronIcon,
CopyButton,
+ errorText,
PlusIcon,
Sheet,
SlotList,
useNavigation,
useObject,
+ useOptimisticCall,
useReadable,
} from "@p2/app/v1/web";
import { useEffect, useRef, useState } from "react";
@@ -68,7 +70,20 @@ function configOf(draft: Draft): EnvConfig {
...(Object.keys(shellEnv).length ? { shellEnv } : {}),
};
}
-const errorText = (reason: unknown): string => (reason instanceof Error ? reason.message : String(reason));
+/** An environment saved or deleted while its call is in flight. */
+type EnvGuess =
+ | { readonly kind: "save"; readonly name: string; readonly config: EnvConfig }
+ | { readonly kind: "delete"; readonly name: string };
+
+/** The list with a save (a new row, or the row's new configuration) or a deletion applied. */
+function applyEnv(envs: EnvSummary[] | undefined, guess: EnvGuess): EnvSummary[] | undefined {
+ if (!envs) return envs;
+ if (guess.kind === "delete") return envs.filter((env) => env.name !== guess.name);
+ const saved = envs.find((env) => env.name === guess.name);
+ return saved
+ ? envs.map((env) => (env === saved ? { ...env, config: guess.config } : env))
+ : [...envs, { name: guess.name, config: guess.config, sessions: 0 }];
+}
function TestResult({
test,
busy,
@@ -252,13 +267,16 @@ function KeySection({
/** The route's locator is the edited environment; an empty locator creates one. Form edits are not steps. */
export function EnvsPanel({ env: screen, onClose }: { env?: string; onClose: () => void }) {
const service = useObject(Envs, {});
- const envs = useReadable(service?.envs);
const loadError = useReadable(service?.failure);
const nav = useNavigation();
const [editing, setEditing] = useState<{ draft: Draft; existing: boolean }>();
const [test, setTest] = useState();
const [busy, setBusy] = useState(false);
const [error, setError] = useState();
+ // Saves and deletions show in the list at once; a refused one rolls back with its error.
+ const [envs, edit] = useOptimisticCall(useReadable(service?.envs), applyEnv, (reason) =>
+ setError(errorText(reason)),
+ );
const [confirmDelete, setConfirmDelete] = useState(false);
const filled = useRef(null);
useEffect(() => {
@@ -298,9 +316,19 @@ export function EnvsPanel({ env: screen, onClose }: { env?: string; onClose: ()
const name = editing?.draft.name.trim() ?? "";
const update = (patch: Partial) =>
setEditing((current) => current && { ...current, draft: { ...current.draft, ...patch } });
- const save = async (testing: boolean) => {
- if (!service || !editing) return;
- await service.save({ name, config: configOf(editing.draft) }, BACKGROUND_CONTEXT);
+ /** Saves the form: the environment is listed (and the form an existing one's) at once; the save's call. */
+ const save = (): Promise | undefined => {
+ if (!service || !editing) return undefined;
+ setError(undefined);
+ let config: EnvConfig;
+ try {
+ config = configOf(editing.draft);
+ } catch (reason) {
+ setError(errorText(reason));
+ return undefined;
+ }
+ const saving = service.save({ name, config }, BACKGROUND_CONTEXT);
+ edit({ kind: "save", name, config }, () => saving);
setEditing((current) => current && { ...current, existing: true });
if (!editing.existing) {
filled.current = name;
@@ -309,8 +337,16 @@ export function EnvsPanel({ env: screen, onClose }: { env?: string; onClose: ()
layers: [...nav.route.layers.slice(0, -1), { kind: "sheet", name: "envs", locator: name }],
});
}
- if (testing) setTest(await service.test({ name }, BACKGROUND_CONTEXT));
+ return saving;
};
+ /** Saves, then tests: long work, so the form shows "Working…" until the test answers. */
+ const saveAndTest = () =>
+ void run(async () => {
+ const saving = save();
+ if (!saving || !service) return;
+ await saving;
+ setTest(await service.test({ name }, BACKGROUND_CONTEXT));
+ });
if (editing) {
const { draft, existing } = editing;
const sessions = envs?.find((env) => env.name === name)?.sessions ?? 0;
@@ -320,7 +356,8 @@ export function EnvsPanel({ env: screen, onClose }: { env?: string; onClose: ()
className="stack env-form"
onSubmit={(event) => {
event.preventDefault();
- void run(() => save(false));
+ // A refused save shows through its rollback.
+ void save()?.catch(() => {});
}}
>
{!existing && (
@@ -425,7 +462,7 @@ export function EnvsPanel({ env: screen, onClose }: { env?: string; onClose: ()
type="button"
className="button primary"
disabled={busy || !name || !draft.host.trim()}
- onClick={() => void run(() => save(true))}
+ onClick={saveAndTest}
>
{busy ? "Working…" : "Save and test"}
@@ -468,12 +505,12 @@ export function EnvsPanel({ env: screen, onClose }: { env?: string; onClose: ()
- void run(async () => {
- await service?.delete({ name }, BACKGROUND_CONTEXT);
- toScreen(undefined, "close");
- })
- }
+ onClick={() => {
+ if (!service) return;
+ setError(undefined);
+ edit({ kind: "delete", name }, () => service.delete({ name }, BACKGROUND_CONTEXT));
+ toScreen(undefined, "close");
+ }}
>
Delete
diff --git a/apps/p2/plugins/envs/server.ts b/apps/p2/plugins/envs/server.ts
index fe53c0a00..c07ebcf0a 100644
--- a/apps/p2/plugins/envs/server.ts
+++ b/apps/p2/plugins/envs/server.ts
@@ -4,19 +4,19 @@ import {
defineFacet,
defineState,
deriveTyped,
+ followNative,
forbidden,
isOwner,
nativeOf,
record,
serveNative,
- untilAborted,
} from "@p2/app/v1";
import { listLocalDirectory } from "@p2/app/v1/node";
import { Hosts, Server } from "@p2/app/v1/server";
import { Catalog, type Session } from "../sessions/contract.ts";
import { StatusStore } from "../status/contract.ts";
import { type Child, ChildStore } from "../subagents/contract.ts";
-import { EnvConfigSchema, envRevision } from "./config.ts";
+import { envRevision } from "./config.ts";
import { EnvManagers, EnvService, Envs, EnvTestSchema } from "./contract.ts";
import { EnvManager } from "./manager.ts";
import { EnvStore } from "./store.ts";
@@ -41,15 +41,11 @@ const Restarts = defineState("p2.env-restarts", {
initial: () => ({ revisions: null, pending: {} }),
});
-/** The observed configuration, its last error, and the latest test of each environment (with its revision). */
-const Observed = defineState("p2.env-service", {
- schema: obj({
- configs: record(EnvConfigSchema),
- tests: record(obj({ revision: str(), test: EnvTestSchema })),
- failure: union([str(), nul()]),
- }),
+/** The latest test of each environment, with the revision of the configuration it tested. */
+const Tests = defineState("p2.env-tests", {
+ schema: obj({ tests: record(obj({ revision: str(), test: EnvTestSchema })) }),
version: 1,
- initial: () => ({ configs: {}, tests: {}, failure: null }),
+ initial: () => ({ tests: {} }),
});
export default defineFacet((host) => {
@@ -98,7 +94,7 @@ export default defineFacet((host) => {
const server = self.get(Server, {});
const catalog = self.get(Catalog, {});
const children = self.get(ChildStore, {});
- const observed = self.state(Observed);
+ const tested = self.state(Tests);
const store = self.open(
async () => {
const opened = new EnvStore(server.info.value.dataDir);
@@ -120,29 +116,21 @@ export default defineFacet((host) => {
return manager.value;
},
});
- self.onLive(async (ctx) => {
- const stop = store.value.subscribe((snapshot) => {
- void observed
- .change((draft) => {
- draft.configs = snapshot.configs;
- draft.failure = snapshot.error;
- }, ctx)
- .catch(() => {});
- });
- try {
- await untilAborted(ctx);
- } finally {
- stop();
- }
- });
+ // envs.json as the store last read it validly, and why the last read failed: `reload` resolves after the store
+ // told this source.
+ const snapshot = followNative(
+ self,
+ (changed) => store.value.subscribe(changed),
+ () => store.value.snapshot,
+ );
return {
- configs: self.derive([observed], ([value]) => value.configs),
- tests: self.derive([observed], ([value]) => value.tests),
- failure: self.derive([observed], ([value]) => value.failure),
+ configs: deriveTyped(self, [snapshot], ([value]) => value.configs),
+ tests: self.derive([tested], ([value]) => value.tests),
+ failure: self.derive([snapshot], ([value]) => value.error),
test: async ({ name }, ctx) => {
const config = await manager.value.config(name);
const test = await manager.value.test(name);
- await observed.change((draft) => {
+ await tested.change((draft) => {
draft.tests[name] = { revision: envRevision(config), test };
}, ctx);
return test;
diff --git a/apps/p2/plugins/envs/store.ts b/apps/p2/plugins/envs/store.ts
index fec581fe5..c1a9e405a 100644
--- a/apps/p2/plugins/envs/store.ts
+++ b/apps/p2/plugins/envs/store.ts
@@ -30,9 +30,9 @@ export class EnvStore {
get snapshot(): EnvSnapshot {
return this.#snapshot;
}
+ /** `listener` hears of every load's snapshot from now on; the result stops it. */
subscribe(listener: (snapshot: EnvSnapshot) => void): () => void {
this.#listeners.add(listener);
- listener(this.#snapshot);
return () => this.#listeners.delete(listener);
}
}
diff --git a/apps/p2/plugins/envs/worker.ts b/apps/p2/plugins/envs/worker.ts
index 2a0b4f396..c28e6d2c7 100644
--- a/apps/p2/plugins/envs/worker.ts
+++ b/apps/p2/plugins/envs/worker.ts
@@ -2,19 +2,10 @@ import { contribute, defineFacet } from "@p2/app/v1";
import { HarnessEnvironments, workerOf } from "@p2/app/v1/worker";
import { loadEnvs } from "./config.ts";
import { remoteSession } from "./remote-env.ts";
-import { EnvStore } from "./store.ts";
export default defineFacet((host) => {
contribute(host, "p2.worker-envs", (self) => {
const worker = workerOf(self);
- self.open(
- async () => {
- const store = new EnvStore(worker().init.dataDir);
- await store.observer.start();
- return store;
- },
- (store) => store.observer.close(),
- );
self.contribute(HarnessEnvironments, {
remote: {
open: async (env) => {
diff --git a/apps/p2/plugins/file-index/worker.ts b/apps/p2/plugins/file-index/worker.ts
index 67a930ff3..e0b0744c3 100644
--- a/apps/p2/plugins/file-index/worker.ts
+++ b/apps/p2/plugins/file-index/worker.ts
@@ -1,7 +1,6 @@
import type { ConversationId } from "@earendil-works/pi-durable";
import type { ExecutionEnv } from "@earendil-works/pi-durable/env";
import { type Ctx, canSend, defineFacet, FileIndex, FileIndexCache, type FileItem, forbidden } from "@p2/app/v1";
-import { durableContext } from "@p2/app/v1/node";
import { conversationEnvironment, harnessOf, isLocalEnvironment, workerOf } from "@p2/app/v1/worker";
import { WorkspaceBackend, workspacePath } from "../workspace/contract.ts";
import { inside, listIndex } from "./listing.ts";
@@ -30,27 +29,24 @@ export default defineFacet((host) => {
* workspace's index, which also reports a private or unavailable working directory.
*/
const listed = async (ctx: Ctx) => {
- const context = durableContext(ctx);
try {
return await worker().liveness.call(async () => {
- const handle = await harness().durable.conversation(conversation as ConversationId, context);
+ const handle = await harness().durable.conversation(conversation as ConversationId, ctx);
if (!handle) return undefined;
- const { env } = await conversationEnvironment(harness().environments, handle.id, context);
+ const { env } = await conversationEnvironment(harness().environments, handle.id, ctx);
if (env === unsupported) return undefined;
if (env.listFiles === undefined) {
unsupported = env;
return undefined;
}
- const root = await env.canonicalPath(env.cwd, context);
+ const root = await env.canonicalPath(env.cwd, ctx);
if (!root.ok) return undefined;
const init = worker().init;
// Decided from the environment, not the location, which lags behind it during a move.
- const privatePath = isLocalEnvironment(env)
- ? await env.canonicalPath(init.dataDir, context)
- : undefined;
+ const privatePath = isLocalEnvironment(env) ? await env.canonicalPath(init.dataDir, ctx) : undefined;
const denied = privatePath === undefined ? [] : [privatePath.ok ? privatePath.value : init.dataDir];
if (denied.some((directory) => inside(directory, root.value))) return undefined;
- const result = await listIndex(env, root.value, denied, context);
+ const result = await listIndex(env, root.value, denied, ctx);
if (result === undefined) unsupported = env;
return result;
});
diff --git a/apps/p2/plugins/history/tools.ts b/apps/p2/plugins/history/tools.ts
index 9f402da4a..a9dbfdeaf 100644
--- a/apps/p2/plugins/history/tools.ts
+++ b/apps/p2/plugins/history/tools.ts
@@ -15,7 +15,7 @@ import {
type Tx,
} from "@earendil-works/pi-durable";
import { arr, int, obj, opt, str, union } from "@earendil-works/pi-schema";
-import type { JsonValue } from "@p2/app/v1";
+import type { Context, JsonValue } from "@p2/app/v1";
import { agentMessage, literal, messageData, peerMessage, SHELL_ENTRY, shellData } from "@p2/app/v1";
import type { HistoryHits } from "../search/contract.ts";
@@ -29,12 +29,15 @@ export const READ_BUDGET = 50_000;
const PREVIEW = 200;
/** Searches the calling conversation in the server's index (the search plugin's worker feed). */
-export type HistorySearch = (query: {
- query: string;
- conversation: number;
- before?: number;
- limit: number;
-}) => Promise
;
+export type HistorySearch = (
+ query: {
+ query: string;
+ conversation: number;
+ before?: number;
+ limit: number;
+ },
+ context: Context,
+) => Promise;
export type HistoryRole = "user" | "assistant" | "marker";
/** An entry as the history tools show it. */
@@ -292,12 +295,10 @@ export function createHistoryExtension(search: HistorySearch, label: (conversati
context,
);
const before = args.before ?? boundary;
- const result = await search({
- query: args.query,
- conversation,
- ...(before === undefined ? {} : { before }),
- limit: args.limit ?? 10,
- });
+ const result = await search(
+ { query: args.query, conversation, ...(before === undefined ? {} : { before }), limit: args.limit ?? 10 },
+ context,
+ );
// Authors from the entries themselves (the index keeps roles only).
const hits = await api.commit(async (tx) => {
const out: { entry: number; role: "user" | "assistant"; author?: string; snippet: string }[] = [];
diff --git a/apps/p2/plugins/history/worker.ts b/apps/p2/plugins/history/worker.ts
index 93ff790b6..41dcdfaf0 100644
--- a/apps/p2/plugins/history/worker.ts
+++ b/apps/p2/plugins/history/worker.ts
@@ -9,8 +9,11 @@ export default defineFacet((host) => {
contribute(host, "p2.history-tools", (self) => {
const worker = workerOf(self);
const extension = createHistoryExtension(
- (query) =>
- worker().root(async (ctx) => expectVariant(await ctx.get(SearchFeed, {}), "worker").history(query, ctx)),
+ (query, context) =>
+ worker().root(
+ async (ctx) => expectVariant(await ctx.get(SearchFeed, {}), "worker").history(query, ctx),
+ context,
+ ),
(conversation) => `${worker().init.values.session.id}#${conversation}`,
);
self.contribute(Extensions, { [extension.name]: extension });
diff --git a/apps/p2/plugins/locations/worker.ts b/apps/p2/plugins/locations/worker.ts
index 73b391396..62728c1e7 100644
--- a/apps/p2/plugins/locations/worker.ts
+++ b/apps/p2/plugins/locations/worker.ts
@@ -24,14 +24,11 @@ export default defineFacet((host) => {
const extension = createLocationExtension({
server: {
envs: (context) =>
- worker().root(
- async (ctx) => (await (await server(ctx)).envs({ origin: "owner" }, ctx)).envs,
- context.abortSignal,
- ),
+ worker().root(async (ctx) => (await (await server(ctx)).envs({ origin: "owner" }, ctx)).envs, context),
directories: (env, path, context) =>
worker().root(
async (ctx) => (await server(ctx)).directories({ origin: "owner", env, path }, ctx),
- context.abortSignal,
+ context,
),
},
locate: (id, read, context) => locateConversation(harness, id, context, read),
diff --git a/apps/p2/plugins/login/contract.ts b/apps/p2/plugins/login/contract.ts
index 7c18535ce..f03bff4df 100644
--- a/apps/p2/plugins/login/contract.ts
+++ b/apps/p2/plugins/login/contract.ts
@@ -48,7 +48,8 @@ const OwnerSchema = obj({ owner: str({ minLength: 1 }) });
* dismisses it, and only its leaving ends it.
*/
export const LoginService = local("p2.login-service", {
- version: 2,
+ /** 3: no `abandon` (the service follows who holds `ProviderLogin`). */
+ version: 3,
params: {},
members: {
login: union([LoginStateSchema, nul()]),
@@ -73,8 +74,6 @@ export const LoginService = local("p2.login-service", {
),
cancel: method(obj({ ...propertiesOf(OwnerSchema), ...propertiesOf(AttemptSchema) })),
dismiss: method(obj({ ...propertiesOf(OwnerSchema), ...propertiesOf(AttemptSchema) })),
- /** Its owner left (no hold of its Providers sheet remains): cancels that owner's unfinished login only. */
- abandon: method(OwnerSchema),
logout: method(ProviderIdSchema),
refresh: method(ProviderIdSchema, obj({ models: num() })),
},
diff --git a/apps/p2/plugins/login/logins.ts b/apps/p2/plugins/login/logins.ts
index e6f56e2da..e8725f5f5 100644
--- a/apps/p2/plugins/login/logins.ts
+++ b/apps/p2/plugins/login/logins.ts
@@ -1,9 +1,9 @@
import { randomUUID } from "node:crypto";
import { join } from "node:path";
import type { AuthPrompt, AuthType } from "@earendil-works/pi-ai";
-import { LockedFile } from "@p2/app/v1/node";
+import { LockedFile, processLogs } from "@p2/app/v1/node";
import type { Resources } from "../models/contract.ts";
-import type { LoginEvent, LoginPrompt } from "./contract.ts";
+import type { LoginEvent, LoginPrompt, LoginState } from "./contract.ts";
type PendingPrompt = { prompt: LoginPrompt; resolve: (value: string) => void };
type Attempt = {
@@ -16,20 +16,42 @@ type Attempt = {
done: Promise;
};
+/** The current login (one at a time) and the principal key of the owner that started it. */
+export type LoginsState = { readonly login: LoginState | null; readonly owner: string | null };
+
+/**
+ * Provider logins (provider-owned api-key and OAuth flows over the shared credentials), one at a time. The flow runs
+ * here, outside Chord; its state changes synchronously with each event, and listeners hear of every change (the
+ * server's `LoginService` follows it).
+ */
export class Logins {
private readonly resources: Resources;
- private readonly emit: (owner: string, event: LoginEvent) => void;
-
private readonly lifetime = new AbortController();
private readonly device: LockedFile;
+ private readonly listeners = new Set<() => void>();
private active: Attempt | undefined;
+ private current: LoginsState = { login: null, owner: null };
- constructor(resources: Resources, emit: (owner: string, event: LoginEvent) => void) {
+ constructor(resources: Resources) {
this.resources = resources;
- this.emit = emit;
this.device = new LockedFile(join(resources.directory, "device.json"));
}
+ get state(): LoginsState {
+ return this.current;
+ }
+
+ /** `listener` hears of every change of `state`; the result stops it. */
+ subscribe(listener: () => void): () => void {
+ this.listeners.add(listener);
+ return () => this.listeners.delete(listener);
+ }
+
+ /** The current login, if `owner` started it. */
+ private mine(owner: string): LoginState | null {
+ return this.current.owner === owner ? this.current.login : null;
+ }
+
start(owner: string, providerId: string, authType: AuthType, remote = false): string {
if (this.lifetime.signal.aborted) throw new Error("Server is shutting down");
if (this.active)
@@ -176,17 +198,31 @@ export class Logins {
return this.active;
}
+ /** Answers the prompt (closed at once); secrets and pasted codes are redacted from the logs first. */
answer(owner: string, attemptId: string, promptId: string, value: string): void {
const attempt = this.owned(owner, attemptId);
const pending = attempt.prompts.get(promptId);
if (!pending) throw new Error("Login prompt is no longer active");
if (pending.prompt.type === "select" && !pending.prompt.options.some((option) => option.id === value))
throw new Error("Select one of the listed options");
+ if (pending.prompt.type === "secret" || pending.prompt.type === "manual_code")
+ processLogs.redactor.addSecret(value);
pending.resolve(value);
}
- cancel(owner: string, attemptId: string): void {
- this.owned(owner, attemptId).controller.abort();
+ /** Cancels the login; resolves once it finished (its state shows "cancelled", or what it reached first). */
+ async cancel(owner: string, attemptId: string): Promise {
+ const attempt = this.owned(owner, attemptId);
+ attempt.controller.abort();
+ await attempt.done;
+ }
+
+ /** Clears `owner`'s finished login `attemptId` from the state. */
+ dismiss(owner: string, attemptId: string): void {
+ const login = this.mine(owner);
+ if (login?.attemptId !== attemptId || login.status === "working") return;
+ this.current = { ...this.current, login: null };
+ this.changed();
}
/** Its owner left (closed the sheet everywhere, or disconnected): end its unfinished login, nobody else's. */
@@ -206,6 +242,22 @@ export class Logins {
await this.resources.refreshModels(providerId, this.lifetime.signal);
}
+ /** Applies `event` of `owner`'s login to the state, redacting the codes and URLs it shows from the logs. */
+ private emit(owner: string, event: LoginEvent): void {
+ if (event.type === "notice" && event.event.type === "device_code")
+ processLogs.redactor.addSecret(event.event.userCode);
+ if (event.type === "notice" && event.event.type === "auth_url") processLogs.redactor.addSecret(event.event.url);
+ this.current = {
+ owner: event.type === "started" ? owner : this.current.owner,
+ login: reduce(this.current.login, event),
+ };
+ this.changed();
+ }
+
+ private changed(): void {
+ for (const listener of this.listeners) listener();
+ }
+
async close(): Promise {
this.lifetime.abort();
this.active?.controller.abort();
@@ -213,3 +265,32 @@ export class Logins {
await this.device.close();
}
}
+
+/** The next state of the current login after `event`. */
+function reduce(state: LoginState | null, event: LoginEvent): LoginState | null {
+ if (event.type === "started")
+ return {
+ attemptId: event.attemptId,
+ providerId: event.providerId,
+ authType: event.authType,
+ status: "working",
+ prompt: null,
+ notices: [],
+ };
+ if (state === null || state.attemptId !== event.attemptId) return state;
+ switch (event.type) {
+ case "prompt":
+ return { ...state, prompt: event.prompt };
+ case "promptClosed":
+ return state.prompt?.id === event.promptId ? { ...state, prompt: null } : state;
+ case "notice":
+ return { ...state, notices: [...state.notices, { id: state.notices.length, event: event.event }] };
+ case "finished":
+ return {
+ ...state,
+ status: event.status,
+ prompt: null,
+ ...(event.message === undefined ? {} : { message: event.message }),
+ };
+ }
+}
diff --git a/apps/p2/plugins/login/server.ts b/apps/p2/plugins/login/server.ts
index deaa9d3ca..d8a37de1a 100644
--- a/apps/p2/plugins/login/server.ts
+++ b/apps/p2/plugins/login/server.ts
@@ -1,127 +1,63 @@
-// Provider login: p2's Logins (provider-owned api-key and OAuth flows) as chord2 objects.
+// Provider login: the server's `Logins` (provider-owned api-key and OAuth flows) followed by `LoginService`, and its
+// view per owner.
-import { nul, obj, str, union } from "@earendil-works/pi-schema";
-import {
- BACKGROUND_CONTEXT,
- defineFacet,
- defineTypedState,
- deriveTyped,
- forbidden,
- isChordError,
- isOwner,
- nativeOf,
- principalKey,
-} from "@p2/app/v1";
-import { processLogs } from "@p2/app/v1/node";
-import { serverOf } from "@p2/app/v1/server";
+import { defineFacet, deriveTyped, followNative, forbidden, isOwner, nativeOf, principalKey } from "@p2/app/v1";
import { ModelConfig, ModelResources } from "../models/contract.ts";
-import { type LoginEvent, LoginService, type LoginState, LoginStateSchema, ProviderLogin } from "./contract.ts";
+import { LoginService, ProviderLogin } from "./contract.ts";
import { Logins } from "./logins.ts";
-/** The next state of the current login after `event`. */
-function reduce(state: LoginState | null, event: LoginEvent): LoginState | null {
- if (event.type === "started")
- return {
- attemptId: event.attemptId,
- providerId: event.providerId,
- authType: event.authType,
- status: "working",
- prompt: null,
- notices: [],
- };
- if (state === null || state.attemptId !== event.attemptId) return state;
- switch (event.type) {
- case "prompt":
- return { ...state, prompt: event.prompt };
- case "promptClosed":
- return state.prompt?.id === event.promptId ? { ...state, prompt: null } : state;
- case "notice":
- return { ...state, notices: [...state.notices, { id: state.notices.length, event: event.event }] };
- case "finished":
- return {
- ...state,
- status: event.status,
- prompt: null,
- ...(event.message === undefined ? {} : { message: event.message }),
- };
- }
-}
-
-/** The current login and the owner that started it (in memory). */
-const Current = defineTypedState("p2.login-service", {
- schema: obj({ login: union([LoginStateSchema, nul()]), owner: union([str(), nul()]) }),
- version: 1,
- initial: () => ({ login: null, owner: null }),
-});
-
export default defineFacet((host) => {
+ const holders = host.references(ProviderLogin);
host.serve(LoginService, (_params, self) => {
// Declared, so the configuration is open before the logins are.
self.get(ModelConfig, {});
const resources = nativeOf(self, ModelResources, "The model configuration");
- const current = self.state(Current);
- // Login flows run outside Chord calls: each event changes the state on its own.
- const apply = (owner: string, event: LoginEvent) =>
- void current
- .change((draft) => {
- if (event.type === "started") draft.owner = owner;
- // A draft's arrays are mutable; the reduced state (readonly prompt options) is the same JSON.
- draft.login = reduce(current.value.login, event) as typeof draft.login;
- }, BACKGROUND_CONTEXT)
- .catch(() => {});
- const loginsResource = self.open(
- async () =>
- new Logins(resources().resources, (owner, event) => {
- if (event.type === "notice" && event.event.type === "device_code")
- processLogs.redactor.addSecret(event.event.userCode);
- if (event.type === "notice" && event.event.type === "auth_url")
- processLogs.redactor.addSecret(event.event.url);
- apply(owner, event);
- }),
- (logins) => logins.close(),
+ const logins = self.open(
+ async () => new Logins(resources().resources),
+ (opened) => opened.close(),
);
- const logins = (): Logins => loginsResource.value;
- /** The current login, if `owner` started it. */
- const mine = (owner: string): LoginState | null => (current.value.owner === owner ? current.value.login : null);
+ // The flow's state as the logins hold it: each method's change is made before it returns.
+ const state = followNative(
+ self,
+ (changed) => logins.value.subscribe(changed),
+ () => logins.value.state,
+ );
+ // An owner leaving the Providers sheet everywhere (no reference of theirs left: closed sheets, disconnects) ends
+ // their unfinished login; another owner's sheet coming and going does not.
+ const abandoned = self.derive([holders, state], ([references, { login, owner }]) =>
+ login?.status === "working" &&
+ owner !== null &&
+ !references.some(({ principal }) => principal !== undefined && principalKey(principal) === owner)
+ ? owner
+ : null,
+ );
+ self.on(abandoned, async ({ value: owner }) => {
+ if (owner !== null) logins.value.disconnect(owner);
+ });
return {
- login: deriveTyped(self, [current], ([value]) => value.login),
- owner: self.derive([current], ([value]) => value.owner),
+ login: deriveTyped(self, [state], ([value]) => value.login),
+ owner: self.derive([state], ([value]) => value.owner),
start: async ({ owner, providerId, authType, remote }) => ({
- attemptId: logins().start(owner, providerId, authType, remote),
+ attemptId: logins.value.start(owner, providerId, authType, remote),
}),
answer: async ({ owner, attemptId, promptId, value }) => {
- const current = mine(owner);
- if (
- current?.attemptId === attemptId &&
- current.prompt?.id === promptId &&
- (current.prompt.type === "secret" || current.prompt.type === "manual_code")
- )
- processLogs.redactor.addSecret(value);
- logins().answer(owner, attemptId, promptId, value);
+ logins.value.answer(owner, attemptId, promptId, value);
return null;
},
cancel: async ({ owner, attemptId }) => {
- logins().cancel(owner, attemptId);
+ await logins.value.cancel(owner, attemptId);
return null;
},
- abandon: async ({ owner }) => {
- logins().disconnect(owner);
- return null;
- },
- dismiss: async ({ owner, attemptId }, ctx) => {
- const login = mine(owner);
- if (login?.attemptId === attemptId && login.status !== "working")
- await current.change((draft) => {
- draft.login = null;
- }, ctx);
+ dismiss: async ({ owner, attemptId }) => {
+ logins.value.dismiss(owner, attemptId);
return null;
},
logout: async ({ providerId }) => {
- await logins().logout(providerId);
+ await logins.value.logout(providerId);
return null;
},
refresh: async ({ providerId }) => {
- await logins().refresh(providerId);
+ await logins.value.refresh(providerId);
return {
models: resources()
.resources.models.getModels()
@@ -137,20 +73,6 @@ export default defineFacet((host) => {
const service = self.get(LoginService, {});
// This owner's logins: one device's (its cookie), the Radius owner's, or the device owner's (every local tab).
const owner = principalKey(principal);
- // This owner leaving the sheet everywhere (release, disconnect) ends its unfinished login; another owner's
- // sheet coming and going does not.
- const server = serverOf(self);
- self.open(
- async () => service,
- async (held) => {
- try {
- await server().root((ctx) => held.abandon({ owner }, ctx));
- } catch (error) {
- // The host is closing (no root starts any more): the login service ends with it.
- if (!isChordError(error, "released")) throw error;
- }
- },
- );
// Prompts and notices (an auth URL, a device code) only for the owner that started the login.
const loginView = deriveTyped(self, [service.login, service.owner], ([login, started]) =>
started === owner ? login : null,
diff --git a/apps/p2/plugins/login/tui.ts b/apps/p2/plugins/login/tui.ts
index 5d4fa2135..64d2c7ae2 100644
--- a/apps/p2/plugins/login/tui.ts
+++ b/apps/p2/plugins/login/tui.ts
@@ -422,10 +422,11 @@ async function logout(ctx: TuiContext, name: string | undefined): Promise
ref.release();
return;
}
+ // Shown at once; a refused sign-out says so after it.
+ ctx.notify(`Logged out of ${lineText(provider.name)}`);
void ref
.logout({ providerId: provider.id }, BACKGROUND_CONTEXT)
- .then(() => ctx.notify(`Logged out of ${lineText(provider.name)}`))
- .catch((error: unknown) => ctx.notify(error instanceof Error ? error.message : String(error), "error"))
+ .catch((error: unknown) => ctx.fail(error))
.finally(() => ref.release());
};
if (!list.length) {
diff --git a/apps/p2/plugins/login/web.tsx b/apps/p2/plugins/login/web.tsx
index 77f6b6437..10ca0ed87 100644
--- a/apps/p2/plugins/login/web.tsx
+++ b/apps/p2/plugins/login/web.tsx
@@ -1,7 +1,6 @@
// The Providers sheet (any owner): sign in to model providers, refresh their catalogs, sign out.
import type { AuthEvent } from "@earendil-works/pi-ai";
-import type { Held } from "@p2/app/v1";
import { BACKGROUND_CONTEXT, contribute, defineFacet } from "@p2/app/v1";
import {
CheckIcon,
@@ -15,6 +14,7 @@ import {
useHost,
useNavigation,
useObject,
+ useOptimisticCall,
useOwner,
useReadable,
useRoute,
@@ -23,8 +23,6 @@ import {
import { type ReactNode, useEffect, useRef, useState } from "react";
import { type LoginState, ProviderLogin } from "./contract.ts";
-const errorText = (reason: unknown): string => (reason instanceof Error ? reason.message : String(reason));
-
/** Links from providers open only over http(s). */
function Link({ url, label }: { url: string; label: string }) {
let safe = false;
@@ -77,20 +75,14 @@ function Notice({ event }: { event: AuthEvent }): ReactNode {
}
}
-function PromptForm({ state, login }: { state: LoginState; login: Held }) {
+/** The provider's question; answering it shows the flow working at once (the next prompt or progress follows). */
+function PromptForm({ state, onAnswer }: { state: LoginState; onAnswer: (promptId: string, value: string) => void }) {
const prompt = state.prompt;
const [value, setValue] = useState("");
- const [sending, setSending] = useState(false);
- const [error, setError] = useState();
if (!prompt) return null;
const answer = (input: string) => {
- setSending(true);
- setError(undefined);
- login
- .answer({ attemptId: state.attemptId, promptId: prompt.id, value: input }, BACKGROUND_CONTEXT)
- .then(() => setValue(""))
- .catch((reason: unknown) => setError(errorText(reason)))
- .finally(() => setSending(false));
+ onAnswer(prompt.id, input);
+ setValue("");
};
if (prompt.type === "select")
return (
@@ -98,19 +90,12 @@ function PromptForm({ state, login }: { state: LoginState; login: Held{prompt.message}
{prompt.options.map((option) => (
- answer(option.id)}
- >
+ answer(option.id)}>
{option.label}
))}
- {error && {error}
}
);
return (
@@ -140,8 +125,7 @@ function PromptForm({ state, login }: { state: LoginState; login: Held setValue(event.target.value)}
/>
- {error && {error}
}
-
+
Continue
@@ -152,8 +136,22 @@ function PromptForm({ state, login }: { state: LoginState; login: Held void }) {
const login = useObject(ProviderLogin, {});
- const providers = useReadable(login?.providers) ?? [];
- const state = useReadable(login?.login) ?? null;
+ // Provider errors go to the app's toast, as in Pi Mobile.
+ const { fail } = useComposerState();
+ // Signing out shows at once; a refused sign-out puts the provider back.
+ const [providers, signOut] = useOptimisticCall(
+ useReadable(login?.providers) ?? [],
+ (list, providerId: string) =>
+ list.map((entry) => (entry.id === providerId ? { ...entry, signedIn: null } : entry)),
+ fail,
+ );
+ // A start, an answer or a cancel shows the flow's next state at once: working (the provider's attempt and its
+ // prompts are the server's, so a start shows the spinner until they arrive), or cancelled.
+ const [state, guessLogin] = useOptimisticCall(
+ useReadable(login?.login) ?? null,
+ (_, next: LoginState) => next,
+ fail,
+ );
const [query, setQuery] = useState("");
const nav = useNavigation();
const route = useRoute();
@@ -184,11 +182,6 @@ function ProvidersSheet({ onClose }: { onClose: () => void }) {
}, [host, login, selected]);
const [result, setResult] = useState();
const [busy, setBusy] = useState(false);
- // Provider errors go to the app's toast, as in Pi Mobile (a prompt's own error stays in its form).
- const { setError } = useComposerState();
- const perform = (action: Promise | undefined) => {
- void action?.catch((reason: unknown) => setError(errorText(reason)));
- };
const providerId = selected;
const provider = providers.find((entry) => entry.id === providerId);
if (login && provider) {
@@ -203,7 +196,17 @@ function ProvidersSheet({ onClose }: { onClose: () => void }) {
{state.notices.map((entry) => (
))}
- {state.prompt && }
+ {state.prompt && (
+
+ guessLogin({ ...state, prompt: null }, () =>
+ login.answer({ attemptId: state.attemptId, promptId, value }, BACKGROUND_CONTEXT),
+ )
+ }
+ />
+ )}
{state.status === "working" && !state.prompt && !state.notices.length && (
)}
@@ -219,7 +222,13 @@ function ProvidersSheet({ onClose }: { onClose: () => void }) {
perform(login.cancel({ attemptId: state.attemptId }, BACKGROUND_CONTEXT))}
+ // A start not answered yet has no attempt to cancel.
+ disabled={!state.attemptId}
+ onClick={() =>
+ guessLogin({ ...state, status: "cancelled", prompt: null, message: "Cancelled." }, () =>
+ login.cancel({ attemptId: state.attemptId }, BACKGROUND_CONTEXT),
+ )
+ }
>
Cancel
@@ -247,7 +256,17 @@ function ProvidersSheet({ onClose }: { onClose: () => void }) {
key={method.type}
className="login-action primary"
onClick={() =>
- perform(login.start({ providerId: provider.id, authType: method.type }, BACKGROUND_CONTEXT))
+ guessLogin(
+ {
+ attemptId: "",
+ providerId: provider.id,
+ authType: method.type,
+ status: "working",
+ prompt: null,
+ notices: [],
+ },
+ () => login.start({ providerId: provider.id, authType: method.type }, BACKGROUND_CONTEXT),
+ )
}
>
{method.label}
@@ -271,7 +290,7 @@ function ProvidersSheet({ onClose }: { onClose: () => void }) {
: "Catalog updated; no models reported.",
),
)
- .catch((reason: unknown) => setError(errorText(reason)))
+ .catch(fail)
.finally(() => setBusy(false));
}}
>
@@ -283,7 +302,9 @@ function ProvidersSheet({ onClose }: { onClose: () => void }) {
perform(login.logout({ providerId: provider.id }, BACKGROUND_CONTEXT))}
+ onClick={() =>
+ signOut(provider.id, () => login.logout({ providerId: provider.id }, BACKGROUND_CONTEXT))
+ }
>
Sign out
diff --git a/apps/p2/plugins/logs/server.ts b/apps/p2/plugins/logs/server.ts
index e8429035a..bae2eb35d 100644
--- a/apps/p2/plugins/logs/server.ts
+++ b/apps/p2/plugins/logs/server.ts
@@ -1,7 +1,7 @@
import { readFile, stat } from "node:fs/promises";
import { join } from "node:path";
import { arr, compile, obj, str } from "@earendil-works/pi-schema";
-import { contribute, defineFacet, deriveTyped, forbidden, isOwner } from "@p2/app/v1";
+import { contribute, defineFacet, deriveTyped, followNative, forbidden, isOwner } from "@p2/app/v1";
import { captureProcessLogs, type LogSnapshot, processLogs } from "@p2/app/v1/node";
import { Server } from "@p2/app/v1/server";
import { LogLineSchema, LogNotices, Logs } from "./contract.ts";
@@ -71,14 +71,16 @@ export default defineFacet((host) => {
host.serve(LogNotices, (_params, principal, self) => {
if (!isOwner(principal)) throw forbidden("Notices are only available to owners");
- const notices = self.writable(processLogs.notices());
- self.open(
- async () => processLogs.events.subscribe(() => notices.set(processLogs.notices())),
- (stop) => stop(),
+ // The process's notices as it keeps them: a dismissal tells this source before `dismiss` returns.
+ const notices = followNative(
+ self,
+ (changed) => processLogs.events.subscribe(changed),
+ () => processLogs.notices(),
);
return {
variant: "owner",
members: {
+ // Diffed: the process tells of every log line, and the notices rarely change with one.
notices: deriveTyped(self, [notices], ([value]) => value),
dismiss: async ({ id }) => {
processLogs.dismissNotice(id);
@@ -90,17 +92,21 @@ export default defineFacet((host) => {
host.serve(Logs, ({ source }, principal, self) => {
if (!isOwner(principal)) throw forbidden("Logs are only available to owners");
- const sources = self.writable(processLogs.sources());
- const lines = self.writable(processLogs.lines(source));
- self.open(
- async () =>
- processLogs.events.subscribe((changed) => {
- sources.set(processLogs.sources());
- if (changed === source || !processLogs.sources().some((entry) => entry.id === source))
- lines.set(processLogs.lines(source));
- }),
- (stop) => stop(),
+ const sources = followNative(
+ self,
+ (changed) => processLogs.events.subscribe(changed),
+ () => processLogs.sources(),
);
+ // Read again only when this source changed (or went), not for every line of another one.
+ const lines = followNative(
+ self,
+ (changed) =>
+ processLogs.events.subscribe((changedSource) => {
+ if (changedSource === source || !processLogs.sources().some((entry) => entry.id === source)) changed();
+ }),
+ () => processLogs.lines(source),
+ );
+ // Diffed: a frame carries the lines and sources that changed, not the whole buffer.
return {
variant: "owner",
members: {
diff --git a/apps/p2/plugins/logs/tui.ts b/apps/p2/plugins/logs/tui.ts
index c8f185a0e..593d250f5 100644
--- a/apps/p2/plugins/logs/tui.ts
+++ b/apps/p2/plugins/logs/tui.ts
@@ -9,6 +9,7 @@ import type { TuiContext } from "@p2/app/v1/tui";
import {
ListOverlay,
lineText,
+ type OptimisticAdd,
type TuiCommand,
TuiCommands,
type TuiRuntime,
@@ -30,8 +31,11 @@ function sourceLabel(source: string): string {
export class NoticeWatch implements TuiSurface {
readonly runtime: TuiRuntime;
private scope: TuiScope | undefined;
- private service: () => Held | undefined = () => undefined;
- notices: Notice[] = [];
+ private service: Held | undefined;
+ /** The notices as shown (those dismissed while their calls are in flight left out), and how to dismiss one. */
+ private shown: () => Notice[] = () => this.received;
+ private dismissing: OptimisticAdd | undefined;
+ private received: Notice[] = [];
private seen = new Set();
private first = true;
private stop: () => void;
@@ -40,6 +44,10 @@ export class NoticeWatch implements TuiSurface {
const changed = (): void => {
if (runtime.role !== "owner" || this.scope) return;
this.scope = new TuiScope(runtime);
+ [this.shown, this.dismissing] = this.scope.optimistic(
+ () => this.received,
+ (notices, id: number) => notices.filter((notice) => notice.id !== id),
+ );
this.service = this.scope.watch(LogNotices, {}, (ref, follow) =>
follow(ref.notices, (notices) => this.arrived(notices ?? [])),
);
@@ -48,8 +56,18 @@ export class NoticeWatch implements TuiSurface {
this.stop = () => runtime.listeners.delete(changed);
changed();
}
+ /** The notices as shown: those dismissed meanwhile left out. */
+ get notices(): Notice[] {
+ return this.shown();
+ }
+ /** Dismisses notice `id`: it leaves the list at once and comes back if the server refuses. */
+ dismiss(id: number): void {
+ const service = liveIn(this.service, "owner");
+ if (service && this.dismissing)
+ this.runtime.perform(this.dismissing(id, service.dismiss({ id }, BACKGROUND_CONTEXT)));
+ }
private arrived(notices: Notice[]): void {
- this.notices = notices;
+ this.received = notices;
const fresh = notices.filter((notice) => !this.seen.has(notice.id));
for (const notice of notices) this.seen.add(notice.id);
if (!fresh.length) return;
@@ -61,10 +79,6 @@ export class NoticeWatch implements TuiSurface {
this.runtime.notify(`${sourceLabel(notice.source)}: ${lineText(notice.message)}`, "warning");
this.first = false;
}
- owner(): Held | undefined {
- const ref = this.service();
- return liveIn(ref, "owner");
- }
render(): string[] {
return [];
}
@@ -102,11 +116,7 @@ function openNotices(ctx: TuiContext): void {
id: "app.session.delete",
label: "dismiss",
run: (row) => {
- const service = watch.owner();
- if (row && service?.variant === "owner")
- void service
- .dismiss({ id: Number(row.key) }, BACKGROUND_CONTEXT)
- .catch((error: unknown) => ctx.notify(String(error), "error"));
+ if (row) watch.dismiss(Number(row.key));
},
},
],
@@ -139,9 +149,7 @@ function openLogs(ctx: TuiContext, initial: string | undefined): void {
ctx.requestRender();
}),
];
- void whenLive(current).catch((error: unknown) =>
- ctx.notify(error instanceof Error ? error.message : String(error), "error"),
- );
+ void whenLive(current).catch((error: unknown) => ctx.fail(error));
};
acquire();
const overlay = ctx.overlay(
diff --git a/apps/p2/plugins/logs/web.tsx b/apps/p2/plugins/logs/web.tsx
index eb9710797..249c4738f 100644
--- a/apps/p2/plugins/logs/web.tsx
+++ b/apps/p2/plugins/logs/web.tsx
@@ -1,6 +1,7 @@
-import { defineFacet, whenLive } from "@p2/app/v1";
+import { contribute, defineFacet, whenLive } from "@p2/app/v1";
import {
closeLayers,
+ errorText,
openLayer,
Sheet,
Slots,
@@ -14,7 +15,6 @@ import {
import { useEffect, useRef, useState } from "react";
import { Logs } from "./contract.ts";
import "./style.css";
-import { contribute } from "@p2/app/v1";
function sourceLabel(source: string): string {
if (source.startsWith("session:")) return `Worker ${source.slice(8)}`;
@@ -41,7 +41,7 @@ function LogsSheet({ source }: { source: string }) {
useEffect(() => {
let current = true;
void (logs ? whenLive(logs) : Promise.resolve()).catch((error: unknown) => {
- if (current) setMessage(error instanceof Error ? error.message : String(error));
+ if (current) setMessage(errorText(error));
});
return () => {
current = false;
@@ -104,7 +104,12 @@ function LogsSheet({ source }: { source: string }) {
{message && {message} }
- {text || (status === "live" ? "No log lines" : status === "gone" ? "Logs unavailable" : "Loading logs…")}
+ {text ||
+ (status === "live"
+ ? "No log lines"
+ : status === "failed" || status === "ended"
+ ? "Logs unavailable"
+ : "Loading logs…")}
);
diff --git a/apps/p2/plugins/mcp/config.ts b/apps/p2/plugins/mcp/config.ts
index 4a94c4a2f..57bf79b78 100644
--- a/apps/p2/plugins/mcp/config.ts
+++ b/apps/p2/plugins/mcp/config.ts
@@ -38,14 +38,8 @@ export type OwnerServer =
/** The owner's servers and why entries were skipped. */
export type OwnerConfig = { readonly servers: readonly OwnerServer[]; readonly errors: readonly string[] };
-/**
- * The file as last read: not yet, a valid configuration, or why it is not one (its readers keep the last valid servers,
- * also from before this reader existed: a reload's new reader may first see a save caught half-way).
- */
-type OwnerConfigRead =
- | { readonly kind: "unread" }
- | { readonly kind: "valid"; readonly config: OwnerConfig }
- | { readonly kind: "invalid"; readonly failure: string };
+/** The file as last read: the last valid servers and their errors, and why the last read failed (null: it did not). */
+export type OwnerConfigState = OwnerConfig & { readonly failure: string | null };
/**
* The owner's configuration in `text` (`{ "mcpServers": { name: entry } }`, comments allowed): invalid entries are
@@ -158,32 +152,40 @@ export function expandHostVariables(value: string): string {
export class OwnerConfigFile {
/** Watches the file and reads it at each change; `start()` reads it the first time, `close()` stops. */
readonly observer: Observer;
- #read: OwnerConfigRead = { kind: "unread" };
- readonly #listeners = new Set<(read: OwnerConfigRead) => void>();
+ #state: OwnerConfigState;
+ readonly #listeners = new Set<() => void>();
- constructor(dataDir: string) {
+ /**
+ * `last`: the servers last read validly before this reader existed (a restarted manager's: its new reader may first
+ * see a save caught half-way, and keeps them then).
+ */
+ constructor(dataDir: string, last: OwnerConfig) {
const path = join(dataDir, "mcp.json");
+ this.#state = { ...last, failure: null };
this.observer = new Observer(
[{ env: new NodeExecutionEnv({ cwd: dataDir }), targets: () => [{ path }] }],
async (retrying) => {
try {
- this.#read = { kind: "valid", config: parseOwnerConfig((await optionalText(path)) ?? "{}") };
+ this.#state = { ...parseOwnerConfig((await optionalText(path)) ?? "{}"), failure: null };
} catch (error) {
- // A save caught half-way is read once more before it counts.
+ // A save caught half-way is read once more before it counts; an invalid read keeps the last valid servers.
if (!retrying) return true;
- this.#read = { kind: "invalid", failure: error instanceof Error ? error.message : String(error) };
+ this.#state = { ...this.#state, failure: error instanceof Error ? error.message : String(error) };
}
- for (const listener of this.#listeners) listener(this.#read);
+ for (const listener of this.#listeners) listener();
return false;
},
2000,
);
}
- /** `listener` gets the last read at once (once there is one), then every read's. */
- subscribe(listener: (read: OwnerConfigRead) => void): () => void {
+ get state(): OwnerConfigState {
+ return this.#state;
+ }
+
+ /** `listener` hears of every read from now on; the result stops it. */
+ subscribe(listener: () => void): () => void {
this.#listeners.add(listener);
- if (this.#read.kind !== "unread") listener(this.#read);
return () => this.#listeners.delete(listener);
}
}
diff --git a/apps/p2/plugins/mcp/routes.ts b/apps/p2/plugins/mcp/routes.ts
index 463c47dc6..7ab0a2281 100644
--- a/apps/p2/plugins/mcp/routes.ts
+++ b/apps/p2/plugins/mcp/routes.ts
@@ -4,16 +4,15 @@
// p2 host; for any other owner a probe page finds out first (`LoopbackProbe`).
import type { IncomingMessage, ServerResponse } from "node:http";
-import { type Ctx, isOwner, type StoreSelf, until } from "@p2/app/v1";
-import { type RequestAccess, Routes, type ServerParts, serverOf } from "@p2/app/v1/server";
+import { type Ctx, isOwner, type StoreSelf } from "@p2/app/v1";
+import { type RequestAccess, Routes } from "@p2/app/v1/server";
import { McpManager } from "./contract.ts";
import { resultPage, sendPage, sendProbePage } from "./pages.ts";
/** Contributes the sign-in routes. */
export function signInRoutes(self: StoreSelf): void {
- const server = serverOf(self);
self.contribute(Routes, {
- "GET /mcp/sign-in/*": (request, response, access, ctx) => signIn(request, response, access, server, ctx),
+ "GET /mcp/sign-in/*": (request, response, access, ctx) => signIn(request, response, access, ctx),
"GET /mcp/callback": (request, response, access, ctx) => callback(request, response, access, ctx),
});
}
@@ -29,7 +28,6 @@ async function signIn(
request: IncomingMessage,
response: ServerResponse,
{ principal, origin }: RequestAccess,
- server: () => ServerParts,
ctx: Ctx,
): Promise {
if (!isOwner(principal)) return refuse(response, principal === undefined);
@@ -64,28 +62,10 @@ async function signIn(
}
if (start.kind === "authorized")
return sendPage(response, 200, resultPage(`Signed in to ${name}`, "You may close this page."));
- keepManager(name, server);
response.writeHead(302, { Location: start.url });
response.end();
}
-/**
- * Keeps the manager, which holds the flow, while the sign-in to `name` waits for its browser step (until it finishes,
- * fails or expires), also when no view holds it meanwhile.
- */
-function keepManager(name: string, server: () => ServerParts): void {
- void server()
- .root(async (ctx) => {
- const manager = await ctx.get(McpManager, {});
- await until(
- manager.signIns,
- (signIns) => !signIns.some((entry) => entry.server === name && entry.status === "waiting"),
- ctx,
- );
- })
- .catch(() => {});
-}
-
/** Completes a route sign-in with the authorization response the browser brings back. */
async function callback(
request: IncomingMessage,
diff --git a/apps/p2/plugins/mcp/server.ts b/apps/p2/plugins/mcp/server.ts
index deb156d36..378ec7d36 100644
--- a/apps/p2/plugins/mcp/server.ts
+++ b/apps/p2/plugins/mcp/server.ts
@@ -6,7 +6,7 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
-import { arr, int, nul, obj, str, union, unknown } from "@earendil-works/pi-schema";
+import { arr, int, obj, str, union, unknown } from "@earendil-works/pi-schema";
import {
type Ctx,
contribute,
@@ -15,6 +15,7 @@ import {
deriveTyped,
type Facet,
type Factory,
+ followNative,
forbidden,
type HandleOf,
isOwner,
@@ -23,7 +24,6 @@ import {
LocationSchema,
literal,
locationText,
- type Native,
nativeOf,
PluginError,
type Ref,
@@ -32,7 +32,6 @@ import {
signalOf,
unsafe,
until,
- untilAborted,
type Watchable,
type WorkerPrincipal,
} from "@p2/app/v1";
@@ -41,8 +40,8 @@ import type { EnvConfig } from "../envs/config.ts";
import { EnvManagers, EnvService } from "../envs/contract.ts";
import { Catalog, type Session } from "../sessions/contract.ts";
import { type Child, ChildStore } from "../subagents/contract.ts";
-import { type OwnerConfig, OwnerConfigFile, type OwnerServer } from "./config.ts";
-import { InstanceSchema, Mcp, McpManager, type ProjectFile, SignInSchema } from "./contract.ts";
+import { type OwnerConfig, OwnerConfigFile, type OwnerConfigState, type OwnerServer } from "./config.ts";
+import { type Instance, InstanceSchema, Mcp, McpManager, type ProjectFile } from "./contract.ts";
import { McpCredentials } from "./credentials.ts";
import type { ConnectEnv } from "./environments.ts";
import { type ClientInfo, Instances, RECONNECT } from "./instances.ts";
@@ -88,16 +87,6 @@ export function mcpServerFacet(viewLingerMs: number, connector: EnvConnector): F
});
}
-/**
- * The owner's configuration as last validly read, and why the last read failed (null: it did not). In memory, kept
- * across a reload: a new reader seeing a save caught half-way keeps these servers.
- */
-const Configured = defineTypedState("p2.mcp.config", {
- schema: obj({ servers: unsafe(unknown()), errors: arr(str()), failure: union([str(), nul()]) }),
- version: 1,
- initial: () => ({ servers: [], errors: [], failure: null }),
-});
-
/** The leases of session workers; kept across a reload of the plugin, so the new supervisor starts what they use. */
const Leased = defineTypedState("p2.mcp.leases", {
schema: obj({
@@ -120,46 +109,41 @@ const Leased = defineTypedState("p2.mcp.leases", {
});
/**
- * The supervisor's instances as it last listed them (a reload's new supervisor starts with their tools), and the
- * sign-ins under way.
+ * What a restarted manager's natives start from (in memory, kept across a reload's restart; never shown): the servers
+ * last read validly (a new reader may first see a save caught half-way) and the instances as last listed (their tools
+ * stay listed until their servers answer again).
*/
-const Listed = defineTypedState("p2.mcp.instances", {
- schema: obj({ instances: arr(InstanceSchema), signIns: arr(SignInSchema) }),
- version: 2,
- initial: () => ({ instances: [], signIns: [] }),
+const Memento = defineTypedState("p2.mcp.memento", {
+ schema: obj({ servers: unsafe(unknown()), errors: arr(str()), instances: arr(InstanceSchema) }),
+ version: 1,
+ initial: () => ({ servers: [], errors: [], instances: [] }),
});
-/** The manager: its documents, the file and the supervisor as natives, and the routines that keep them in step. */
+/**
+ * The manager: the file, the supervisor and the sign-ins as natives it follows, the leases as its document, and the
+ * routine that starts and closes instances as they change.
+ */
function managerFactory(connector: EnvConnector): Factory {
return (_params, self) => {
const server = self.get(Server, {});
const envs = self.get(EnvService, {});
- const work = workEnvironments(self.get(Catalog, {}), self.get(ChildStore, {}), self);
- const configured = self.state(Configured);
+ const catalog = self.get(Catalog, {});
+ const store = self.get(ChildStore, {});
+ const work = deriveTyped(self, [catalog.sessions, store.children], ([sessions, children]) =>
+ workEnvironments(sessions, children),
+ );
const leased = self.state(Leased);
- const listed = self.state(Listed);
+ const memento = self.state(Memento);
const placed = placements();
- const placement = () => placed(configured.value, leased.value.leases, envs.configs.value, work.value);
const connect = connector(self);
const file = self.open(
async () => {
- const opened = new OwnerConfigFile(server.info.value.dataDir);
+ const opened = new OwnerConfigFile(server.info.value.dataDir, memento.value);
await opened.observer.start();
return opened;
},
(opened) => opened.observer.close(),
);
- const instances = self.open(
- async () => {
- // After a reload the leases are still here: the routine below starts what they use, with the tools listed
- // before. Not here: natives open before derives compute.
- const info = await clientInfo(server.info.value.app);
- return new Instances(info, RECONNECT, listed.value.instances, connect, (oauth) =>
- signIns.value.authProvider(oauth),
- );
- },
- (opened) => opened.close(),
- );
const signIns = self.open(
async () =>
new SignIns(
@@ -170,11 +154,42 @@ function managerFactory(connector: EnvConnector): Factory {
),
(opened) => opened.close(),
);
+ const instances = self.open(
+ async () => {
+ const info = await clientInfo(server.info.value.app);
+ const opened = new Instances(info, RECONNECT, memento.value.instances, connect, (oauth) =>
+ signIns.value.authProvider(oauth),
+ );
+ // What the leases use now starts at once (after a restart with the tools listed before), so the supervisor's
+ // first list already shows it; derives compute only after the natives opened, so placed from the sources.
+ const at = workEnvironments(catalog.sessions.value, store.children.value);
+ opened.reconcile(placed(file.value.state, leased.value.leases, envs.configs.value, at));
+ return opened;
+ },
+ (opened) => opened.close(),
+ );
const probe = self.open(
async () => new LoopbackProbe(),
(opened) => opened.close(),
);
- self.onLive((ctx) => followNatives(file, instances, signIns, configured, listed, ctx));
+ // The file, the supervisor and the sign-ins as they are: every method's change is made before it returns.
+ const configured = followNative(
+ self,
+ (changed) => file.value.subscribe(changed),
+ () => file.value.state,
+ );
+ const listed = followNative(
+ self,
+ (changed) => {
+ const stops = [instances.value.subscribe(changed), signIns.value.subscribe(changed)];
+ return () => {
+ for (const stop of stops) stop();
+ };
+ },
+ () => ({ instances: instances.value.list(), signIns: signIns.value.list() }),
+ );
+ keepMemento(self, memento, configured, listed);
+ const placement = () => placed(configured.value, leased.value.leases, envs.configs.value, work.value);
// The current configuration, leases, environments and places of work, then every change of them, start and close
// instances; methods that change leases reconcile at once too, so a call right after `setLocations` finds its
// instance.
@@ -209,7 +224,6 @@ function managerFactory(connector: EnvConnector): Factory {
...managerMethods(leased, () => instances.value, placement),
...signInMethods(
configured,
- listed,
() => signIns.value,
() => probe.value,
),
@@ -217,14 +231,36 @@ function managerFactory(connector: EnvConnector): Factory {
};
}
+/**
+ * Keeps the memento a restarted manager's natives start from: the servers of each valid read, and the instances once
+ * one of them lists other tools (instances closing change no tools, so a manager ending writes nothing).
+ */
+function keepMemento(
+ self: Self,
+ memento: HandleOf,
+ configured: Watchable,
+ listed: Watchable<{ readonly instances: readonly Instance[] }>,
+): void {
+ self.on(configured, async ({ value: { servers, errors, failure } }, ctx) => {
+ if (failure === null) await memento.change((draft) => Object.assign(draft, { servers, errors }), ctx);
+ });
+ self.on(listed, async ({ value: { instances } }, ctx) => {
+ const remembered = new Map(memento.value.instances.map((instance) => [instance.id, toolNames(instance)]));
+ if (instances.some((instance) => remembered.get(instance.id) !== toolNames(instance)))
+ await memento.change((draft) => Object.assign(draft, { instances }), ctx);
+ });
+}
+
+/** The names of the tools `instance` lists, as one string. */
+const toolNames = (instance: Instance): string => instance.tools.map((tool) => tool.name).join("\n");
+
/** The environments each session's conversations work on, as the catalog and the subagent store record them. */
function workEnvironments(
- catalog: Ref,
- store: Ref,
- self: Self,
-): Watchable {
- return deriveTyped(self, [catalog.sessions, store.children], ([sessions, children]) =>
- Object.fromEntries(sessions.map((session) => [session.id, sessionEnvironments(session, children[session.id])])),
+ sessions: readonly Session[],
+ children: Readonly>,
+): WorkEnvironments {
+ return Object.fromEntries(
+ sessions.map((session) => [session.id, sessionEnvironments(session, children[session.id])]),
);
}
@@ -254,52 +290,6 @@ function placements(): (
};
}
-/** Copies what the file reads, the supervisor lists and the sign-ins are into the manager's documents until `ctx` ends. */
-async function followNatives(
- file: Native,
- instances: Native,
- signIns: Native,
- configured: HandleOf,
- listed: HandleOf,
- ctx: Ctx,
-): Promise {
- // Changes heard while this routine stops would change state with an ended ctx.
- const show = (): void => {
- if (signalOf(ctx).aborted) return;
- void listed
- .change((draft) => {
- draft.instances = instances.value.list();
- draft.signIns = signIns.value.list();
- }, ctx)
- .catch(() => {});
- };
- const stops = [
- file.value.subscribe((read) => {
- if (signalOf(ctx).aborted) return;
- void configured
- .change((draft) => {
- // An invalid read keeps the servers last read validly.
- if (read.kind === "invalid") draft.failure = read.failure;
- else if (read.kind === "valid")
- Object.assign(draft, {
- servers: [...read.config.servers],
- errors: [...read.config.errors],
- failure: null,
- });
- }, ctx)
- .catch(() => {});
- }),
- instances.value.subscribe(show),
- signIns.value.subscribe(show),
- ];
- show();
- try {
- await untilAborted(ctx);
- } finally {
- for (const stop of stops) stop();
- }
-}
-
/** The manager's methods: the lease rules applied on the leases' line, calls through the leased instance, restarts. */
function managerMethods(leased: HandleOf, instances: () => Instances, placement: () => Placement) {
return {
@@ -341,12 +331,7 @@ function managerMethods(leased: HandleOf, instances: () => Instan
}
/** The manager's sign-in methods: flows of the owner's OAuth servers, the callback route's completion, the probe. */
-function signInMethods(
- configured: HandleOf,
- listed: HandleOf,
- signIns: () => SignIns,
- probe: () => LoopbackProbe,
-) {
+function signInMethods(configured: Watchable, signIns: () => SignIns, probe: () => LoopbackProbe) {
const oauthServer = (name: string): OAuthServer => {
const server = configured.value.servers.find((candidate) => candidate.name === name);
const oauth = server === undefined ? undefined : oauthServerOf(server);
@@ -354,14 +339,9 @@ function signInMethods(
return oauth;
};
return {
- beginSignIn: async ({ server, redirect }: { server: string; redirect: SignInRedirect }, ctx: Ctx) => {
- const start = await asPluginError(signIns().begin(oauthServer(server), redirect));
- // Listed before the answer: a caller following `signIns` for this flow's end sees it under way first.
- await listed.change((draft) => {
- draft.signIns = signIns().list();
- }, ctx);
- return start;
- },
+ // The flow is listed before the answer: a caller following `signIns` for its end sees it under way first.
+ beginSignIn: ({ server, redirect }: { server: string; redirect: SignInRedirect }) =>
+ asPluginError(signIns().begin(oauthServer(server), redirect)),
completeSignIn: ({ state, code, iss, error }: { state: string; code?: string; iss?: string; error?: string }) =>
signIns().complete(
error !== undefined || code === undefined
diff --git a/apps/p2/plugins/mcp/tui.ts b/apps/p2/plugins/mcp/tui.ts
index 95d665a03..b4fe6389a 100644
--- a/apps/p2/plugins/mcp/tui.ts
+++ b/apps/p2/plugins/mcp/tui.ts
@@ -7,7 +7,6 @@ import {
BACKGROUND_CONTEXT,
contribute,
defineFacet,
- follow,
type Held,
type HeldIn,
liveIn,
@@ -39,48 +38,51 @@ const messageOf = (error: unknown): string => (error instanceof Error ? error.me
/** `/mcp [paste ]`: the overlay, or a pasted redirect URL for the sign-in waiting for it. */
function openMcp(ctx: TuiContext, args: string | undefined): void {
- const ref = ctx.host.hold(Mcp, {}, BACKGROUND_CONTEXT);
const words = args?.trim().split(/\s+/).filter(Boolean) ?? [];
if (words[0] === "paste") {
+ const ref = ctx.host.hold(Mcp, {}, BACKGROUND_CONTEXT);
void paste(ctx, ref, words[1]).finally(() => ref.release());
return;
}
- let instances: Instance[] = [];
+ const scope = ctx.scope();
+ let received: Instance[] = [];
let signIns: SignIn[] = [];
let status = "";
- let closed = false;
- const stops: (() => void)[] = [];
- void whenLive(ref)
- .then(() => {
- const view = liveIn(ref, "owner");
- if (closed || view === undefined) return;
- stops.push(
- follow(view.instances, (value) => {
- instances = value;
- ctx.requestRender();
- }),
- follow(view.signIns, (value) => {
- signIns = value;
- ctx.requestRender();
- }),
- );
- })
- .catch(() => {});
- const run = (label: string, action: (view: OwnerView) => Promise) => {
- const view = liveIn(ref, "owner");
+ const held = scope.watch(Mcp, {}, (mcp, follow) => {
+ const view = liveIn(mcp, "owner");
+ if (!view) return;
+ follow(view.instances, (value) => {
+ received = value;
+ });
+ follow(view.signIns, (value) => {
+ signIns = value;
+ });
+ });
+ // A restart shows the server starting and a sign-out signed out at once; the status line follows the call.
+ type StatusGuess = { instance: string; status: Instance["status"] };
+ const [instances, changing] = scope.optimistic(
+ () => received,
+ (list, guess: StatusGuess) =>
+ list.map((instance) => (instance.id === guess.instance ? { ...instance, status: guess.status } : instance)),
+ );
+ const run = (label: string, action: (view: OwnerView) => Promise, guess?: StatusGuess) => {
+ const view = liveIn(held, "owner");
if (view === undefined) return;
status = `${label}…`;
- ctx.requestRender();
- void action(view)
- .then(() => {
+ const call = (async () => {
+ try {
+ await action(view);
status = `${label}: done`;
- })
- .catch((error: unknown) => {
+ } catch (error) {
status = `${label}: ${messageOf(error)}`;
- })
- .finally(() => ctx.requestRender());
+ throw error;
+ }
+ })();
+ // The status line shows how it ended, a failure included.
+ void (guess ? changing(guess, call) : call).catch(() => ctx.requestRender());
+ ctx.requestRender();
};
- const byRow = (row: ListRow | undefined) => instances.find((instance) => instance.id === row?.key);
+ const byRow = (row: ListRow | undefined) => instances().find((instance) => instance.id === row?.key);
const signIn = (view: OwnerView, server: string) =>
view.signIn({ server }, BACKGROUND_CONTEXT).then(async (start) => {
if (start.kind === "authorized") return;
@@ -92,9 +94,9 @@ function openMcp(ctx: TuiContext, args: string | undefined): void {
const overlay = ctx.overlay(
"mcp",
new ListOverlay(ctx, {
- title: () => `MCP servers (${instances.length})`,
+ title: () => `MCP servers (${instances().length})`,
rows: () =>
- instances.map((instance) => {
+ instances().map((instance) => {
const flow = signIns.find((entry) => entry.server === instance.server);
const place = [
instance.location ? locationText(instance.location) : "",
@@ -127,8 +129,10 @@ function openMcp(ctx: TuiContext, args: string | undefined): void {
run: (row) => {
const instance = byRow(row);
if (instance)
- run(`Restarting ${instance.server}`, (view) =>
- view.restart({ instance: instance.id }, BACKGROUND_CONTEXT),
+ run(
+ `Restarting ${instance.server}`,
+ (view) => view.restart({ instance: instance.id }, BACKGROUND_CONTEXT),
+ { instance: instance.id, status: "starting" },
);
},
},
@@ -147,17 +151,17 @@ function openMcp(ctx: TuiContext, args: string | undefined): void {
run: (row) => {
const instance = byRow(row);
if (instance?.auth === "oauth")
- run(`Signing out of ${instance.server}`, (view) =>
- view.signOut({ server: instance.server }, BACKGROUND_CONTEXT),
+ run(
+ `Signing out of ${instance.server}`,
+ (view) => view.signOut({ server: instance.server }, BACKGROUND_CONTEXT),
+ { instance: instance.id, status: "sign-in" },
);
},
},
],
footer: () => (status ? [ctx.theme.current.fg("muted", ` ${lineText(status)}`)] : []),
onClose: () => {
- closed = true;
- for (const stop of stops) stop();
- ref.release();
+ scope.dispose();
overlay.close();
},
}),
diff --git a/apps/p2/plugins/mcp/web.tsx b/apps/p2/plugins/mcp/web.tsx
index 49cc59d51..fdf27d698 100644
--- a/apps/p2/plugins/mcp/web.tsx
+++ b/apps/p2/plugins/mcp/web.tsx
@@ -6,11 +6,13 @@ import { BACKGROUND_CONTEXT, contribute, defineFacet, type HeldIn, locationText
import {
ChevronIcon,
closeLayers,
+ errorText,
openLayer,
Sheet,
Slots,
useComposerState,
useObject,
+ useOptimisticCall,
useOwner,
useReadable,
useSheet,
@@ -20,7 +22,29 @@ import { type Instance, Mcp, type SignIn, signInLink } from "./contract.ts";
type OwnerView = HeldIn;
-const errorText = (reason: unknown): string => (reason instanceof Error ? reason.message : String(reason));
+/** A change of the instances while its call is in flight: a restart starts it, a sign-out leaves it signed out. */
+type InstanceGuess =
+ | { readonly kind: "restart"; readonly instance: string }
+ | { readonly kind: "signOut"; readonly server: string };
+
+function applyInstance(instances: readonly Instance[], guess: InstanceGuess): readonly Instance[] {
+ return instances.map((instance) =>
+ guess.kind === "restart"
+ ? instance.id === guess.instance
+ ? { ...instance, status: "starting", error: undefined }
+ : instance
+ : instance.server === guess.server && instance.auth === "oauth"
+ ? { ...instance, status: "sign-in" }
+ : instance,
+ );
+}
+
+/** What a server's entry does; each shows its effect at once and rolls back with the error in the toast. */
+type Actions = {
+ readonly restart: (instance: string) => void;
+ readonly signOut: (server: string) => void;
+ readonly cancelSignIn: (id: string) => void;
+};
const STATUS: Record = {
starting: "Starting",
@@ -32,10 +56,22 @@ const STATUS: Record = {
/** The sheet: configuration errors, then each server with its instances and sign-in. */
function McpSheet({ onClose }: { onClose: () => void }) {
const view = useObject(Mcp, {}, { variant: "owner" });
- const instances = useReadable(view?.instances) ?? [];
const errors = useReadable(view?.errors) ?? [];
- const signIns = useReadable(view?.signIns) ?? [];
+ const { fail } = useComposerState();
+ const [instances, changeInstance] = useOptimisticCall(useReadable(view?.instances) ?? [], applyInstance, fail);
+ const [signIns, cancel] = useOptimisticCall(
+ useReadable(view?.signIns) ?? [],
+ (list, id: string) => list.filter((entry) => entry.id !== id),
+ fail,
+ );
const servers = [...new Set(instances.map((instance) => instance.server))];
+ const actions: Actions | undefined = view && {
+ restart: (instance) =>
+ changeInstance({ kind: "restart", instance }, () => view.restart({ instance }, BACKGROUND_CONTEXT)),
+ signOut: (server) =>
+ changeInstance({ kind: "signOut", server }, () => view.signOut({ server }, BACKGROUND_CONTEXT)),
+ cancelSignIn: (id) => cancel(id, () => view.cancelSignIn({ id }, BACKGROUND_CONTEXT)),
+ };
return (
@@ -48,12 +84,13 @@ function McpSheet({ onClose }: { onClose: () => void }) {
))}
{view && servers.length === 0 && No MCP server is running.
}
- {view && (
+ {view && actions && (
{servers.map((server) => (
instance.server === server)}
signIn={signIns.find((entry) => entry.server === server)}
@@ -68,17 +105,17 @@ function McpSheet({ onClose }: { onClose: () => void }) {
/** One server: its instances (status, place, tools, error, restart), and signing in or out if it uses OAuth. */
function ServerEntry({
view,
+ actions,
server,
instances,
signIn,
}: {
view: OwnerView;
+ actions: Actions;
server: string;
instances: readonly Instance[];
signIn: SignIn | undefined;
}) {
- const { setError } = useComposerState();
- const perform = (action: Promise) => void action.catch((reason: unknown) => setError(errorText(reason)));
const oauth = instances.some((instance) => instance.auth === "oauth");
const needsSignIn = instances.some((instance) => instance.status === "sign-in");
const connected = instances.some((instance) => instance.status === "connected");
@@ -100,25 +137,19 @@ function ServerEntry({
{instance.error && instance.status !== "sign-in" && {instance.error}
}
- perform(view.restart({ instance: instance.id }, BACKGROUND_CONTEXT))}
- >
+ actions.restart(instance.id)}>
Restart
))}
- {oauth && signIn?.status === "waiting" && }
+ {oauth && signIn?.status === "waiting" && (
+ actions.cancelSignIn(signIn.id)} />
+ )}
{oauth && signIn?.status === "failed" && (
{signIn.error ?? "The sign-in failed."}
-
perform(view.cancelSignIn({ id: signIn.id }, BACKGROUND_CONTEXT))}
- >
+ actions.cancelSignIn(signIn.id)}>
Dismiss
@@ -132,11 +163,7 @@ function ServerEntry({
) : null}
{connected && (
- perform(view.signOut({ server }, BACKGROUND_CONTEXT))}
- >
+ actions.signOut(server)}>
Sign out
)}
@@ -147,7 +174,7 @@ function ServerEntry({
}
/** A sign-in waiting for the browser: paste the final redirect URL, or cancel. */
-function PendingSignIn({ view, signIn }: { view: OwnerView; signIn: SignIn }) {
+function PendingSignIn({ view, signIn, onCancel }: { view: OwnerView; signIn: SignIn; onCancel: () => void }) {
const [value, setValue] = useState("");
const [sending, setSending] = useState(false);
const [error, setError] = useState();
@@ -184,11 +211,7 @@ function PendingSignIn({ view, signIn }: { view: OwnerView; signIn: SignIn }) {
Continue
- void view.cancelSignIn({ id: signIn.id }, BACKGROUND_CONTEXT).catch(() => {})}
- >
+
Cancel
diff --git a/apps/p2/plugins/mcp/worker.ts b/apps/p2/plugins/mcp/worker.ts
index b266d09bf..d7af81e5d 100644
--- a/apps/p2/plugins/mcp/worker.ts
+++ b/apps/p2/plugins/mcp/worker.ts
@@ -64,10 +64,7 @@ function mcpWorker(self: StoreSelf): void {
const view = self.get(Mcp, {});
const call: McpCall = async ({ conversationId, read, ...request }, context) => {
const location = await harness().environments.locate(conversationId, context, read);
- return worker().root(
- (ctx) => expectVariant(view, "worker").call({ location, ...request }, ctx),
- context.abortSignal,
- );
+ return worker().root((ctx) => expectVariant(view, "worker").call({ location, ...request }, ctx), context);
};
// Code mode switched off or not loaded: the tools would be unreachable, so `tool_search` loads them, as pi falls back.
const reach = self.derive(
diff --git a/apps/p2/plugins/models/server.ts b/apps/p2/plugins/models/server.ts
index 7c4d69271..84bc3ee57 100644
--- a/apps/p2/plugins/models/server.ts
+++ b/apps/p2/plugins/models/server.ts
@@ -1,36 +1,14 @@
-import { obj, unknown } from "@earendil-works/pi-schema";
-import {
- defineFacet,
- defineState,
- forbidden,
- isOwner,
- type JsonValue,
- unsafe,
- untilAborted,
- type Watchable,
-} from "@p2/app/v1";
+import { defineFacet, deriveTyped, followNative, forbidden, isOwner } from "@p2/app/v1";
import { Server, serverOf } from "@p2/app/v1/server";
import { openResources, resourceState } from "./config.ts";
-import { ModelConfig, ModelResources, ModelSummarySchema, Models } from "./contract.ts";
-import type { ResourceSnapshot } from "./resources.ts";
+import { ModelConfig, ModelResources, Models } from "./contract.ts";
import { resolveModelScope } from "./scope.ts";
-/** What `ModelConfig` publishes of the configuration: the picker's summary and the secret-free snapshot. */
-const Published = defineState("p2.model-config", {
- schema: obj({ summary: ModelSummarySchema, snapshot: unsafe(unknown()) }),
- version: 1,
- initial: () => ({
- summary: { models: [], defaultModel: null, errors: [] },
- snapshot: { version: 0, settings: {}, credentials: [], providers: [], models: [], errors: [] },
- }),
-});
-
export default defineFacet((host) => {
host.serve(ModelResources, { compose: (table) => table[0]?.value });
host.serve(ModelConfig, (_params, self) => {
const server = self.get(Server, {});
const natives = serverOf(self);
- const published = self.state(Published);
const resources = self.open(
() => openResources(server.info.value.dataDir, server.info.value.fake, natives().resourceOptions),
(opened) => opened.close(),
@@ -40,25 +18,16 @@ export default defineFacet((host) => {
return resources.value;
},
});
- self.onLive(async (ctx) => {
- const publish = () =>
- published.change((draft) => {
- const state = resourceState(resources.value, server.info.value.fake);
- draft.summary = state.summary;
- draft.snapshot = state.snapshot as unknown as JsonValue;
- }, ctx);
- await publish();
- const stop = resources.value.subscribe(() => void publish().catch(() => {}));
- try {
- await untilAborted(ctx);
- } finally {
- stop();
- }
- });
+ // The configuration files' state as the resources hold it: a save below resolves after its reload, which
+ // told this source, so the caller's answer comes with the new state.
+ const state = followNative(
+ self,
+ (changed) => resources.value.subscribe(changed),
+ () => resourceState(resources.value, server.info.value.fake),
+ );
return {
- summary: self.derive([published], ([value]) => value.summary),
- // The snapshot is JSON (`Resources.snapshot()`); its type is the one the contract names.
- snapshot: self.derive([published], ([value]) => value.snapshot) as unknown as Watchable,
+ summary: self.derive([state], ([value]) => value.summary),
+ snapshot: deriveTyped(self, [state], ([value]) => value.snapshot),
setDefault: async ({ provider, modelId, thinkingLevel }) => {
const opened = resources.value;
if (!opened.models.getModel(provider, modelId)) throw new Error("Unknown model");
diff --git a/apps/p2/plugins/models/tui.ts b/apps/p2/plugins/models/tui.ts
index d3fbc83e4..08875aaca 100644
--- a/apps/p2/plugins/models/tui.ts
+++ b/apps/p2/plugins/models/tui.ts
@@ -10,6 +10,7 @@ import type { TuiContext } from "@p2/app/v1/tui";
import {
lineText,
ModelSelectorComponent,
+ type OptimisticAdd,
ScopedModelsSelectorComponent,
type SelectorModel,
ThinkingSelectorComponent,
@@ -25,6 +26,22 @@ import { type ModelChoice, type ModelInfo, Models } from "./contract.ts";
type Scoped = { provider: string; modelId: string; thinkingLevel?: string };
+/** A choice in flight: the conversation's model and level, the default, or the scope. */
+type Guess = {
+ readonly model?: ModelMeta | null;
+ readonly thinkingLevel?: string;
+ readonly defaultModel?: ModelChoice | null;
+ readonly scoped?: Scoped[];
+};
+
+/** What the commands read: the state with the choices in flight applied. */
+type Shown = {
+ readonly model: ModelMeta | null;
+ readonly thinkingLevel: string | undefined;
+ readonly defaultModel: ModelChoice | null;
+ readonly scoped: Scoped[];
+};
+
/**
* What the commands need, held per runtime (a non-screen slot): the owner's catalog, scope and default, and the viewed
* conversation's model and thinking level.
@@ -35,18 +52,22 @@ export class ModelState implements TuiSurface {
private owner: TuiScope | undefined;
private chat: TuiScope | undefined;
private identity: string | undefined;
- private models: () => Held | undefined = () => undefined;
- private chatRef: () => Held | undefined = () => undefined;
+ private models: Held | undefined;
+ private chatRef: Held | undefined;
+ /** The state as shown: the choices in flight over what the server last sent. */
+ private readonly shownState: () => Shown;
+ /** Shows a choice while its call is in flight: the selectors and cycling start from it (Ctrl+P twice moves twice). */
+ readonly choosing: OptimisticAdd;
catalog: ModelInfo[] = [];
- scoped: Scoped[] = [];
- defaultModel: ModelChoice | null = null;
- /** The viewed conversation's model (guest-safe metadata) and thinking level. */
- model: ModelMeta | null = null;
- thinkingLevel: string | undefined;
+ private received: Shown = { model: null, thinkingLevel: undefined, defaultModel: null, scoped: [] };
private stop: () => void;
constructor(runtime: TuiRuntime) {
this.runtime = runtime;
this.scope = new TuiScope(runtime);
+ [this.shownState, this.choosing] = this.scope.optimistic(
+ () => this.received,
+ (shown, guess: Guess) => ({ ...shown, ...guess }),
+ );
const changed = (): void => {
// The catalog is owner-only: guests never request it.
if (runtime.role === "owner" && !this.owner) {
@@ -56,10 +77,10 @@ export class ModelState implements TuiSurface {
this.catalog = models ?? [];
});
follow(ref.scopedModels, (scoped) => {
- this.scoped = scoped ?? [];
+ this.received = { ...this.received, scoped: scoped ?? [] };
});
follow(ref.defaultModel, (value) => {
- this.defaultModel = value ?? null;
+ this.received = { ...this.received, defaultModel: value ?? null };
});
});
}
@@ -67,19 +88,18 @@ export class ModelState implements TuiSurface {
this.identity = runtime.identity;
this.chat?.dispose();
this.chat = undefined;
- this.chatRef = () => undefined;
- this.model = null;
- this.thinkingLevel = undefined;
+ this.chatRef = undefined;
+ this.received = { ...this.received, model: null, thinkingLevel: undefined };
const session = runtime.session;
if (!session) return;
this.chat = new TuiScope(runtime);
this.chatRef = this.chat.watch(Chat, { session, conversation: runtime.conversation }, (ref, follow) => {
follow(ref.model, (model) => {
- this.model = model ?? null;
+ this.received = { ...this.received, model: model ?? null };
});
follow(ref.view, (view) => {
const agent = view?.docs["pi.agent"] as { thinkingLevel?: string } | undefined;
- this.thinkingLevel = agent?.thinkingLevel;
+ this.received = { ...this.received, thinkingLevel: agent?.thinkingLevel };
});
});
};
@@ -87,15 +107,30 @@ export class ModelState implements TuiSurface {
this.stop = () => runtime.listeners.delete(changed);
changed();
}
+ /** The state as shown: the choices in flight over what the server last sent. */
+ private get shown(): Shown {
+ return this.shownState();
+ }
+ /** The viewed conversation's model (guest-safe metadata). */
+ get model(): ModelMeta | null {
+ return this.shown.model;
+ }
+ get thinkingLevel(): string | undefined {
+ return this.shown.thinkingLevel;
+ }
+ get defaultModel(): ModelChoice | null {
+ return this.shown.defaultModel;
+ }
+ get scoped(): Scoped[] {
+ return this.shown.scoped;
+ }
/** The owner's Models reference once live. */
service(): Held | undefined {
- const ref = this.models();
- return liveIn(ref, "owner");
+ return liveIn(this.models, "owner");
}
/** The viewed conversation's participant reference once live. */
participant(): HeldIn | undefined {
- const ref = this.chatRef();
- return liveIn(ref, "participant");
+ return liveIn(this.chatRef, "participant");
}
selectorModels(): SelectorModel[] {
return this.catalog.map(({ provider, modelId, name }) => ({ provider, modelId, name }));
@@ -122,39 +157,79 @@ const describe = (state: ModelState): string =>
? `${lineText(state.model.provider)}/${lineText(state.model.modelId)} · thinking ${lineText(state.thinkingLevel ?? "off")}`
: "no model";
-/** Applies a model and/or level: the conversation's (Chat.configure) with a session, else the default. */
-async function apply(ctx: TuiContext, state: ModelState, change: { model?: SelectorModel; thinkingLevel?: string }) {
- const chat = ctx.identity.session ? state.participant() : undefined;
+/** A model and/or level a command chose. */
+type Change = { readonly model?: SelectorModel; readonly thinkingLevel?: string };
+
+/** The conversation's model as shown while a change to `model` is in flight: the catalog's facts about it. */
+function metaOf(state: ModelState, model: SelectorModel): ModelMeta {
+ const info = state.catalog.find((entry) => entry.provider === model.provider && entry.modelId === model.modelId);
+ const thinkingLevels = info?.thinkingLevels ?? [];
+ return {
+ provider: model.provider,
+ modelId: model.modelId,
+ name: info?.name ?? model.name,
+ contextWindow: info?.contextWindow ?? 0,
+ reasoning: thinkingLevels.length > 1,
+ thinkingLevels,
+ };
+}
+
+/**
+ * Applies a change: the conversation's (Chat.configure) with a session, else the default. The choice shows at once
+ * (the notice, and the selectors and cycling start from it); a refused call takes it back and says why.
+ */
+function choose(ctx: TuiContext, state: ModelState, change: Change, done: string): void {
if (ctx.identity.session) {
- if (!chat) throw new Error("This conversation is not connected or read-only");
- await chat.configure(
- {
- ...(change.model ? { model: { provider: change.model.provider, modelId: change.model.modelId } } : {}),
- ...(change.thinkingLevel ? { thinkingLevel: change.thinkingLevel } : {}),
- },
- BACKGROUND_CONTEXT,
+ const chat = state.participant();
+ if (!chat) {
+ ctx.notify("This conversation is not connected or read-only", "error");
+ return;
+ }
+ const guess = {
+ ...(change.model ? { model: metaOf(state, change.model) } : {}),
+ ...(change.thinkingLevel ? { thinkingLevel: change.thinkingLevel } : {}),
+ };
+ ctx.notify(done);
+ ctx.perform(
+ state.choosing(
+ guess,
+ chat.configure(
+ {
+ ...(change.model
+ ? { model: { provider: change.model.provider, modelId: change.model.modelId } }
+ : {}),
+ ...(change.thinkingLevel ? { thinkingLevel: change.thinkingLevel } : {}),
+ },
+ BACKGROUND_CONTEXT,
+ ),
+ ),
);
return;
}
- const service = state.service();
const model = change.model ?? state.defaultModel;
- if (!service || !model) throw new Error("Models are not available");
- await service.setDefault(
- {
- provider: model.provider,
- modelId: model.modelId,
- thinkingLevel: change.thinkingLevel ?? state.defaultModel?.thinkingLevel ?? "off",
- },
- BACKGROUND_CONTEXT,
- );
+ if (model) setDefault(ctx, state, { ...model, thinkingLevel: change.thinkingLevel }, done);
}
-const perform = (ctx: TuiContext, action: Promise, done?: string) =>
- void action
- .then(() => done && ctx.notify(done))
- .catch((error: unknown) => {
- ctx.notify(error instanceof Error ? error.message : String(error), "error");
- });
+/** Sets the default for new sessions (keeping the default's level unless `thinkingLevel` is given), shown at once. */
+function setDefault(
+ ctx: TuiContext,
+ state: ModelState,
+ choice: { provider: string; modelId: string; thinkingLevel: string | undefined },
+ done: string,
+): void {
+ const service = state.service();
+ if (!service) {
+ ctx.notify("Models are not available", "error");
+ return;
+ }
+ const defaultModel = {
+ provider: choice.provider,
+ modelId: choice.modelId,
+ thinkingLevel: choice.thinkingLevel ?? state.defaultModel?.thinkingLevel ?? "off",
+ };
+ ctx.notify(done);
+ ctx.perform(state.choosing({ defaultModel }, service.setDefault(defaultModel, BACKGROUND_CONTEXT)));
+}
/** Guests (and the owner without the catalog yet) see the model read-only. */
function readOnly(ctx: TuiContext, state: ModelState | undefined): boolean {
@@ -184,24 +259,13 @@ function openModelSelector(ctx: TuiContext, initialSearch?: string): void {
...(initialSearch ? { initialSearch } : {}),
onSelect: (model) => {
handle.close();
- perform(ctx, apply(ctx, state, { model }), `Model: ${model.provider}/${model.modelId}`);
+ choose(ctx, state, { model }, `Model: ${model.provider}/${model.modelId}`);
},
onSelectAsDefault: (model) => {
handle.close();
- const service = state.service();
- if (!service) return;
const thinkingLevel = state.thinkingLevel ?? state.defaultModel?.thinkingLevel ?? "off";
- perform(
- ctx,
- Promise.all([
- service.setDefault(
- { provider: model.provider, modelId: model.modelId, thinkingLevel },
- BACKGROUND_CONTEXT,
- ),
- ...(ctx.identity.session ? [apply(ctx, state, { model })] : []),
- ]),
- `Default model: ${model.provider}/${model.modelId}`,
- );
+ setDefault(ctx, state, { ...model, thinkingLevel }, `Default model: ${model.provider}/${model.modelId}`);
+ if (ctx.identity.session) choose(ctx, state, { model }, `Model: ${model.provider}/${model.modelId}`);
},
onCancel: () => handle.close(),
}),
@@ -222,24 +286,14 @@ function openThinkingSelector(ctx: TuiContext): void {
...(state.defaultModel ? { defaultLevel: state.defaultModel.thinkingLevel } : {}),
onSelect: (level) => {
handle.close();
- perform(ctx, apply(ctx, state, { thinkingLevel: level }), `Thinking level: ${level}`);
+ choose(ctx, state, { thinkingLevel: level }, `Thinking level: ${level}`);
},
onSelectAsDefault: (level) => {
handle.close();
- const service = state.service();
const model = state.model ?? state.defaultModel;
- if (!service || !model) return;
- perform(
- ctx,
- Promise.all([
- service.setDefault(
- { provider: model.provider, modelId: model.modelId, thinkingLevel: level },
- BACKGROUND_CONTEXT,
- ),
- ...(ctx.identity.session ? [apply(ctx, state, { thinkingLevel: level })] : []),
- ]),
- `Default thinking level: ${level}`,
- );
+ if (!model) return;
+ setDefault(ctx, state, { ...model, thinkingLevel: level }, `Default thinking level: ${level}`);
+ if (ctx.identity.session) choose(ctx, state, { thinkingLevel: level }, `Thinking level: ${level}`);
},
onCancel: () => handle.close(),
}),
@@ -260,9 +314,10 @@ function cycleModel(ctx: TuiContext, direction: 1 | -1): void {
const next = ring[(index + direction + ring.length) % ring.length]!;
const info = state.catalog.find((model) => model.provider === next.provider && model.modelId === next.modelId);
const model = { provider: next.provider, modelId: next.modelId, name: info?.name ?? next.modelId };
- perform(
+ choose(
ctx,
- apply(ctx, state, { model, ...(next.thinkingLevel ? { thinkingLevel: next.thinkingLevel } : {}) }),
+ state,
+ { model, ...(next.thinkingLevel ? { thinkingLevel: next.thinkingLevel } : {}) },
`Model: ${next.provider}/${next.modelId}${next.thinkingLevel ? ` · thinking ${next.thinkingLevel}` : ""}`,
);
}
@@ -278,7 +333,7 @@ function cycleThinking(ctx: TuiContext): void {
}
const current = state.thinkingLevel ?? "off";
const next = levels[(levels.indexOf(current) + 1) % levels.length]!;
- perform(ctx, apply(ctx, state, { thinkingLevel: next }), `Thinking level: ${next}`);
+ choose(ctx, state, { thinkingLevel: next }, `Thinking level: ${next}`);
}
function openScopedModels(ctx: TuiContext): void {
@@ -292,12 +347,14 @@ function openScopedModels(ctx: TuiContext): void {
onSave: (patterns) => {
handle.close();
const service = state.service();
- if (service)
- perform(
- ctx,
- service.setEnabledModels({ patterns }, BACKGROUND_CONTEXT),
- patterns.length ? `Scoped models: ${patterns.length}` : "Model scope cleared",
- );
+ if (!service) return;
+ ctx.notify(patterns.length ? `Scoped models: ${patterns.length}` : "Model scope cleared");
+ // The selector saves exact `provider/modelId` patterns: cycling uses them at once.
+ const scoped = patterns.map((pattern) => {
+ const slash = pattern.indexOf("/");
+ return { provider: pattern.slice(0, slash), modelId: pattern.slice(slash + 1) };
+ });
+ ctx.perform(state.choosing({ scoped }, service.setEnabledModels({ patterns }, BACKGROUND_CONTEXT)));
},
onCancel: () => handle.close(),
}),
@@ -331,7 +388,7 @@ export const MODEL_COMMANDS: Record = {
? state?.catalog.find((model) => `${model.provider}/${model.modelId}`.toLowerCase() === query.toLowerCase())
: undefined;
if (state && exact && !readOnly(ctx, state))
- perform(ctx, apply(ctx, state, { model: exact }), `Model: ${exact.provider}/${exact.modelId}`);
+ choose(ctx, state, { model: exact }, `Model: ${exact.provider}/${exact.modelId}`);
else if (!exact) openModelSelector(ctx, query);
},
},
diff --git a/apps/p2/plugins/models/worker.ts b/apps/p2/plugins/models/worker.ts
index 7807cb5b0..1de156ce2 100644
--- a/apps/p2/plugins/models/worker.ts
+++ b/apps/p2/plugins/models/worker.ts
@@ -1,17 +1,8 @@
-import { obj, unknown } from "@earendil-works/pi-schema";
-import { defineFacet, defineState, unsafe, untilAborted } from "@p2/app/v1";
-import type { MobileSettings } from "@p2/app/v1/node";
+import { defineFacet, followNative } from "@p2/app/v1";
import { HarnessConfig, workerOf } from "@p2/app/v1/worker";
import { openResources } from "./config.ts";
import { WorkerModels } from "./contract.ts";
-/** The worker's current, valid settings (hot-reloaded). */
-const Settings = defineState("p2.worker-models", {
- schema: obj({ settings: unsafe(unknown()) }),
- version: 1,
- initial: () => ({ settings: {} }),
-});
-
export default defineFacet((host) => {
host.serve(WorkerModels, (_params, self) => {
const worker = workerOf(self);
@@ -19,26 +10,18 @@ export default defineFacet((host) => {
() => openResources(worker().init.dataDir, worker().init.fake),
(opened) => opened.close(),
);
- const settings = self.state(Settings);
- self.onLive(async (ctx) => {
- const publish = () =>
- settings.change((draft) => {
- draft.settings = resources.value.settings;
- }, ctx);
- await publish();
- const stop = resources.value.subscribe(() => void publish().catch(() => {}));
- try {
- await untilAborted(ctx);
- } finally {
- stop();
- }
- });
self.contribute(HarnessConfig, {
default: {
models: () => resources.value.models,
settings: () => resources.value.harnessSettings(),
},
});
- return { settings: self.derive([settings], ([value]) => value.settings) };
+ return {
+ settings: followNative(
+ self,
+ (changed) => resources.value.subscribe(changed),
+ () => resources.value.settings,
+ ),
+ };
});
});
diff --git a/apps/p2/plugins/ports/PortsPanel.tsx b/apps/p2/plugins/ports/PortsPanel.tsx
index 554ad37c0..10d39104d 100644
--- a/apps/p2/plugins/ports/PortsPanel.tsx
+++ b/apps/p2/plugins/ports/PortsPanel.tsx
@@ -1,29 +1,51 @@
+import type { Static } from "@earendil-works/pi-schema";
import { BACKGROUND_CONTEXT, liveIn } from "@p2/app/v1";
-import { CopyButton, Sheet, useObject, useReadable } from "@p2/app/v1/web";
+import { CopyButton, errorText, Sheet, useObject, useOptimisticCall, useReadable } from "@p2/app/v1/web";
import { useState } from "react";
-import { Ports } from "./contract.ts";
+import { type ForwardSchema, Ports } from "./contract.ts";
-/** Every forward is visible to the owner. Making one public requires a second explicit tap. */
+type Forward = Static;
+
+/** A change of the list while its call is in flight. */
+type PortGuess =
+ | { readonly kind: "add"; readonly key: string; readonly port: number; readonly name: string | undefined }
+ | { readonly kind: "remove"; readonly id: string }
+ | { readonly kind: "public"; readonly id: string; readonly open: boolean };
+
+/**
+ * The list with a change applied. An added forward's id and addresses are the server's: until it answers, the row
+ * shows the port and name only (no Open, no link).
+ */
+function applyPort(forwards: Forward[] | undefined, guess: PortGuess): Forward[] | undefined {
+ if (!forwards) return forwards;
+ if (guess.kind === "add")
+ return [...forwards, { id: guess.key, port: guess.port, ...(guess.name ? { name: guess.name } : {}) }];
+ if (guess.kind === "remove") return forwards.filter((forward) => forward.id !== guess.id);
+ return forwards.map((forward) =>
+ forward.id === guess.id
+ ? { ...forward, public: guess.open, ...(guess.open ? {} : { publicUrl: undefined }) }
+ : forward,
+ );
+}
+
+/**
+ * Every forward is visible to the owner. Making one public requires a second explicit tap. Adding, removing and the
+ * public switch show at once; a refused one rolls back with its error.
+ */
export function PortsPanel({ onClose }: { onClose: () => void }) {
const service = liveIn(useObject(Ports, {}), "owner");
- const forwards = useReadable(service?.forwards);
const origin = useReadable(service?.radiusOrigin);
const [port, setPort] = useState("");
const [name, setName] = useState("");
const [removing, setRemoving] = useState();
const [publishing, setPublishing] = useState();
- const [busy, setBusy] = useState(false);
const [error, setError] = useState();
- const run = async (action: () => Promise) => {
- setBusy(true);
+ const [forwards, change] = useOptimisticCall(useReadable(service?.forwards), applyPort, (reason) =>
+ setError(errorText(reason)),
+ );
+ const run = (guess: PortGuess, call: () => Promise) => {
setError(undefined);
- try {
- await action();
- } catch (reason) {
- setError(reason instanceof Error ? reason.message : String(reason));
- } finally {
- setBusy(false);
- }
+ change(guess, call);
};
const onDevice = ["127.0.0.1", "localhost", "[::1]"].includes(location.hostname);
return (
@@ -42,11 +64,14 @@ export function PortsPanel({ onClose }: { onClose: () => void }) {
{forwards?.map((forward) => {
const url = forward.url ?? (onDevice ? `http://localhost:${forward.port}/` : undefined);
- const setPublic = (open: boolean) =>
- void run(async () => {
- await service?.setPublic({ id: forward.id, public: open }, BACKGROUND_CONTEXT);
- setPublishing(undefined);
- });
+ const pending = forward.id.startsWith("adding-");
+ const setPublic = (open: boolean) => {
+ setPublishing(undefined);
+ if (service)
+ run({ kind: "public", id: forward.id, open }, () =>
+ service.setPublic({ id: forward.id, public: open }, BACKGROUND_CONTEXT),
+ );
+ };
return (
@@ -63,10 +88,13 @@ export function PortsPanel({ onClose }: { onClose: () => void }) {
- void run(() => service!.remove({ id: forward.id }, BACKGROUND_CONTEXT))
- }
+ onClick={() => {
+ setRemoving(undefined);
+ if (service)
+ run({ kind: "remove", id: forward.id }, () =>
+ service.remove({ id: forward.id }, BACKGROUND_CONTEXT),
+ );
+ }}
>
Remove
@@ -76,7 +104,12 @@ export function PortsPanel({ onClose }: { onClose: () => void }) {
>
) : (
<>
-
setRemoving(forward.id)}>
+ setRemoving(forward.id)}
+ >
Remove
{forward.url && }
@@ -94,12 +127,7 @@ export function PortsPanel({ onClose }: { onClose: () => void }) {
Anyone with the link can open localhost:{forward.port} without signing in.
- setPublic(true)}
- >
+ setPublic(true)}>
Make public
setPublishing(undefined)}>
@@ -111,7 +139,7 @@ export function PortsPanel({ onClose }: { onClose: () => void }) {
event.target.checked ? setPublishing(forward.id) : setPublic(false)
}
@@ -134,14 +162,13 @@ export function PortsPanel({ onClose }: { onClose: () => void }) {
className="inline-form"
onSubmit={(event) => {
event.preventDefault();
- void run(async () => {
- await service?.add(
- { port: Number(port.trim()), ...(name.trim() ? { name: name.trim() } : {}) },
- BACKGROUND_CONTEXT,
- );
- setPort("");
- setName("");
- });
+ if (!service) return;
+ const added = { port: Number(port.trim()), ...(name.trim() ? { name: name.trim() } : {}) };
+ run({ kind: "add", key: `adding-${crypto.randomUUID()}`, port: added.port, name: added.name }, () =>
+ service.add(added, BACKGROUND_CONTEXT),
+ );
+ setPort("");
+ setName("");
}}
>
void }) {
placeholder="Name (optional)"
aria-label="Name"
/>
-
+
Add
diff --git a/apps/p2/plugins/ports/server.ts b/apps/p2/plugins/ports/server.ts
index afff5f9b4..2bd4ac45a 100644
--- a/apps/p2/plugins/ports/server.ts
+++ b/apps/p2/plugins/ports/server.ts
@@ -1,9 +1,7 @@
-import { arr, nul, obj, str, union } from "@earendil-works/pi-schema";
import {
- BACKGROUND_CONTEXT,
defineFacet,
- defineTypedState,
deriveTyped,
+ followNative,
forbidden,
isOwner,
isWorker,
@@ -12,43 +10,30 @@ import {
} from "@p2/app/v1";
import { Hosts, Server } from "@p2/app/v1/server";
import { Catalog, SessionArchived } from "../sessions/contract.ts";
-import { ForwardSchema, ForwardStore, ForwardsNative, Ports } from "./contract.ts";
+import { ForwardStore, ForwardsNative, Ports } from "./contract.ts";
import { forwardView } from "./protocol.ts";
import { Forwards } from "./store.ts";
-/** The forwards' list and Radius origin as the store shows them (in memory). */
-const Listed = defineTypedState("p2.forward-store", {
- schema: obj({ list: arr(ForwardSchema), radiusOrigin: union([str(), nul()]) }),
- version: 1,
- initial: () => ({ list: [], radiusOrigin: null }),
-});
-
export default defineFacet((host) => {
serveNative(host, ForwardsNative);
host.serve(ForwardStore, (_params, self) => {
const server = self.get(Server, {});
const catalog = self.get(Catalog, {});
- const listed = self.state(Listed);
- const show = (store: Forwards) =>
- listed.change((draft) => {
- // A draft's arrays are mutable; the store's list is the same JSON.
- draft.list = store.list() as typeof draft.list;
- draft.radiusOrigin = store.radiusOrigin;
- }, BACKGROUND_CONTEXT);
const forwards = self.open(
async () => {
const store = await Forwards.open(server.info.value.dataDir);
// Forwards of archived sessions close first, so the list never shows them.
for (const session of catalog.sessions.value)
if (session.archivedAt !== null) await store.closeSession(session.id);
- await show(store);
return store;
},
() => {},
);
- self.open(
- async () => forwards.value.events.subscribe(() => void show(forwards.value).catch(() => {})),
- (stop) => stop(),
+ // The store's list and Radius origin: a method's write resolves after the store told this source.
+ const listed = followNative(
+ self,
+ (changed) => forwards.value.events.subscribe(changed),
+ () => ({ list: forwards.value.list(), radiusOrigin: forwards.value.radiusOrigin }),
);
self.contribute(ForwardsNative, {
get forwards() {
diff --git a/apps/p2/plugins/ports/tui.ts b/apps/p2/plugins/ports/tui.ts
index 6fa75c108..bbc8f9fe9 100644
--- a/apps/p2/plugins/ports/tui.ts
+++ b/apps/p2/plugins/ports/tui.ts
@@ -3,7 +3,7 @@
// (p2-tui-port.md section 3.5); OSC 8 hyperlinks stay stripped. `/ports` (P11f) is the web's ports panel.
import { Text } from "@earendil-works/pi-tui";
-import { BACKGROUND_CONTEXT, contribute, defineFacet, follow, liveIn, whenLive } from "@p2/app/v1";
+import { BACKGROUND_CONTEXT, contribute, defineFacet, type HeldIn, liveIn, whenLive } from "@p2/app/v1";
import type { TuiContext } from "@p2/app/v1/tui";
import {
confirm,
@@ -58,70 +58,62 @@ export const portsRenderers: ToolRenderers = {
},
};
+/** A change of the list while its call is in flight; an added forward's id and addresses are the server's. */
+type PortGuess =
+ | { kind: "add"; key: string; port: number; name?: string; public?: boolean }
+ | { kind: "remove"; id: string }
+ | { kind: "public"; id: string; open: boolean };
+
+function applyPort(forwards: ForwardView[], guess: PortGuess): ForwardView[] {
+ if (guess.kind === "add")
+ return [
+ ...forwards,
+ { id: guess.key, port: guess.port, ...(guess.name ? { name: guess.name } : {}), public: guess.public },
+ ];
+ if (guess.kind === "remove") return forwards.filter((forward) => forward.id !== guess.id);
+ return forwards.map((forward) => (forward.id === guess.id ? { ...forward, public: guess.open } : forward));
+}
+
/**
* `/ports [open [name] [public]]` (P11f), after the web's ports panel: the owner's forwards with their
* addresses; Ctrl+P makes one public or private (public only after a confirmation: anyone with the address can open
* it), Ctrl+D closes one. `open` adds a forward; a public one is confirmed first.
*/
function openPorts(ctx: TuiContext, args: string | undefined): void {
- const ref = ctx.host.hold(Ports, {}, BACKGROUND_CONTEXT);
- const owner = () => liveIn(ref, "owner");
- let forwards: ForwardView[] = [];
- let status = "";
- let closed = false;
- let stop = (): void => {};
- // The forwards are the owner variant's: subscribed once the reference knows its variant.
- void whenLive(ref)
- .then(() => {
- if (closed || ref.variant !== "owner") return;
- stop = follow(ref.forwards, (value) => {
- forwards = value;
- ctx.requestRender();
+ const scope = ctx.scope();
+ let received: ForwardView[] = [];
+ const ref = scope.watch(Ports, {}, (held, follow) => {
+ if (held.variant === "owner")
+ follow(held.forwards, (value) => {
+ received = value;
});
- })
- .catch(() => {});
- const run = (label: string, action: () => Promise) => {
- status = `${label}…`;
- void action()
- .then(() => {
- status = `${label}: done`;
- })
- .catch((error: unknown) => {
- status = `${label}: ${error instanceof Error ? error.message : String(error)}`;
- })
- .finally(() => ctx.requestRender());
- };
+ });
+ // Adding, closing and the public switch show at once; a refused one rolls back with its error.
+ const [forwards, changing] = scope.optimistic(() => received, applyPort);
+ const change = (guess: PortGuess, call: (service: HeldIn) => Promise) =>
+ ctx.perform(
+ changing(
+ guess,
+ (async () => {
+ // `/ports open` runs before the list arrived: the call waits for the reference.
+ await whenLive(ref);
+ const service = liveIn(ref, "owner");
+ if (!service) throw new Error("Ports are the owner's");
+ await call(service);
+ })(),
+ ),
+ );
const publicWarning = (port: number) => [
`Make port ${port} public?`,
"Anyone with its public address can open it without signing in.",
];
- const words = args?.trim().split(/\s+/).filter(Boolean) ?? [];
- if (words[0] === "open") {
- const port = Number(words[1]);
- const isPublic = words.includes("public");
- const name = words.slice(2).find((word) => word !== "public");
- if (!Number.isInteger(port) || port < 1 || port > 65535) ctx.notify("Usage: /ports open [name] [public]");
- else
- void whenLive(ref)
- .then(async () => {
- if (isPublic && !(await confirm(ctx, publicWarning(port)))) return;
- const service = owner();
- if (!service) return;
- await service.add(
- { port, ...(name ? { name } : {}), ...(isPublic ? { public: true } : {}) },
- BACKGROUND_CONTEXT,
- );
- ctx.notify(`Forwarding port ${port}`);
- })
- .catch((error: unknown) => ctx.notify(error instanceof Error ? error.message : String(error), "error"));
- }
- const byRow = (row: ListRow | undefined) => forwards.find((forward) => forward.id === row?.key);
+ const byRow = (row: ListRow | undefined) => forwards().find((forward) => forward.id === row?.key);
const overlay = ctx.overlay(
"ports",
new ListOverlay(ctx, {
- title: () => `Ports (${forwards.length})`,
+ title: () => `Ports (${forwards().length})`,
rows: () =>
- forwards.map((forward) => ({
+ forwards().map((forward) => ({
key: forward.id,
label: `${forward.name ?? `Port ${forward.port}`} localhost:${forward.port}${forward.public ? " public" : ""}`,
detail: forward.session ? `by ${forward.sessionTitle ?? "another session"}` : "",
@@ -138,20 +130,13 @@ function openPorts(ctx: TuiContext, args: string | undefined): void {
label: "public/private",
run: (row) => {
const forward = byRow(row);
- const service = owner();
- if (!forward || service?.variant !== "owner") return;
- if (forward.public)
- run(`Making ${forward.port} private`, () =>
- service.setPublic({ id: forward.id, public: false }, BACKGROUND_CONTEXT),
- );
- else
- void confirm(ctx, publicWarning(forward.port)).then(
- (yes) =>
- yes &&
- run(`Making ${forward.port} public`, () =>
- service.setPublic({ id: forward.id, public: true }, BACKGROUND_CONTEXT),
- ),
+ if (!forward || forward.id.startsWith("adding-")) return;
+ const setPublic = (open: boolean) =>
+ change({ kind: "public", id: forward.id, open }, (service) =>
+ service.setPublic({ id: forward.id, public: open }, BACKGROUND_CONTEXT),
);
+ if (forward.public) setPublic(false);
+ else void confirm(ctx, publicWarning(forward.port)).then((yes) => yes && setPublic(true));
},
},
{
@@ -159,25 +144,38 @@ function openPorts(ctx: TuiContext, args: string | undefined): void {
label: "close",
run: (row) => {
const forward = byRow(row);
- const service = owner();
- if (forward && service?.variant === "owner")
- run(`Closing ${forward.port}`, () => service.remove({ id: forward.id }, BACKGROUND_CONTEXT));
+ if (forward && !forward.id.startsWith("adding-"))
+ change({ kind: "remove", id: forward.id }, (service) =>
+ service.remove({ id: forward.id }, BACKGROUND_CONTEXT),
+ );
},
},
],
- footer: () => [
- ctx.theme.current.fg("dim", " /ports open [name] [public] adds one"),
- ...(status ? [ctx.theme.current.fg("muted", ` ${lineText(status)}`)] : []),
- ],
+ footer: () => [ctx.theme.current.fg("dim", " /ports open [name] [public] adds one")],
onClose: () => {
- closed = true;
- stop();
- ref.release();
+ scope.dispose();
overlay.close();
},
}),
{ width: "100%", anchor: "bottom-center" },
);
+ // After the overlay: a confirmation opens on top of it.
+ const words = args?.trim().split(/\s+/).filter(Boolean) ?? [];
+ if (words[0] === "open") {
+ const port = Number(words[1]);
+ const isPublic = words.includes("public");
+ const name = words.slice(2).find((word) => word !== "public");
+ if (!Number.isInteger(port) || port < 1 || port > 65535) ctx.notify("Usage: /ports open [name] [public]");
+ else
+ void (async () => {
+ if (isPublic && !(await confirm(ctx, publicWarning(port)))) return;
+ const added = { port, ...(name ? { name } : {}), ...(isPublic ? { public: true } : {}) };
+ change({ kind: "add", key: `adding-${crypto.randomUUID()}`, ...added }, async (service) => {
+ await service.add(added, BACKGROUND_CONTEXT);
+ ctx.notify(`Forwarding port ${port}`);
+ });
+ })();
+ }
}
const PORT_COMMANDS: Record = {
diff --git a/apps/p2/plugins/ports/worker.ts b/apps/p2/plugins/ports/worker.ts
index 708db0c41..2326675b4 100644
--- a/apps/p2/plugins/ports/worker.ts
+++ b/apps/p2/plugins/ports/worker.ts
@@ -10,7 +10,7 @@ export default defineFacet((host) => {
(query, context) =>
worker().root(
async (ctx) => expectVariant(await ctx.get(Ports, {}), "worker").query({ conversation: 1, query }, ctx),
- context.abortSignal,
+ context,
),
() => worker().init.values.session.id,
);
diff --git a/apps/p2/plugins/powershell/worker.ts b/apps/p2/plugins/powershell/worker.ts
index 1791a7ceb..5d0f592a6 100644
--- a/apps/p2/plugins/powershell/worker.ts
+++ b/apps/p2/plugins/powershell/worker.ts
@@ -12,7 +12,6 @@ import {
} from "@earendil-works/pi-durable";
import { createPowerShellTool } from "@earendil-works/pi-durable/tools";
import { defineFacet, type Location, signalOf } from "@p2/app/v1";
-import { durableContext } from "@p2/app/v1/node";
import { CommitSignal, Extensions, harnessOf, workerOf } from "@p2/app/v1/worker";
import { Subagents } from "../subagents/contract.worker.ts";
import { POWERSHELL_EXTENSION, POWERSHELL_TOOL, PowerShell } from "./contract.ts";
@@ -94,8 +93,7 @@ export default defineFacet((host) => {
const seen = signal.version;
let unknown = false;
try {
- const context = durableContext(ctx);
- const registry = await durable.snapshot(Subagents, ROOT_CONVERSATION_ID, context);
+ const registry = await durable.snapshot(Subagents, ROOT_CONVERSATION_ID, ctx);
const ids: ConversationId[] = [
ROOT_CONVERSATION_ID,
...Object.values(registry?.agents ?? {}).map((agent) => agent.conversationId),
@@ -104,8 +102,8 @@ export default defineFacet((host) => {
const offered: Record = {};
const changes: { id: ConversationId; on: boolean }[] = [];
for (const id of ids) {
- const state = await durable.snapshot(AgentDoc, id, context);
- const windows = await isWindows(await harness().environments.locate(id, context));
+ const state = await durable.snapshot(AgentDoc, id, ctx);
+ const windows = await isWindows(await harness().environments.locate(id, ctx));
if (windows === undefined) unknown = true;
// Unknown keeps what it has: an unreachable machine does not take PowerShell away.
const on = windows === undefined ? offersPowerShell(state) : windows && enables(state, "bash");
@@ -115,7 +113,7 @@ export default defineFacet((host) => {
if (changes.length)
await durable.commit(async (tx) => {
for (const { id, on } of changes) setPowerShell(await tx.doc(AgentDoc, id), on);
- }, context);
+ }, ctx);
offeredState.set(offered);
} catch (error) {
if (signalOf(ctx).aborted) return;
diff --git a/apps/p2/plugins/prompt/tui.ts b/apps/p2/plugins/prompt/tui.ts
index 0c1457bcf..08a117a1d 100644
--- a/apps/p2/plugins/prompt/tui.ts
+++ b/apps/p2/plugins/prompt/tui.ts
@@ -2,7 +2,7 @@
// the viewed conversation's `PromptResources`, and their expansion on the session worker when the editor submits.
import type { Held } from "@p2/app/v1";
-import { BACKGROUND_CONTEXT, contribute, defineFacet, statusOf } from "@p2/app/v1";
+import { BACKGROUND_CONTEXT, contribute, defineFacet, liveIn } from "@p2/app/v1";
import type { TuiContext } from "@p2/app/v1/tui";
import {
lineText,
@@ -21,7 +21,7 @@ export class PromptSurface implements TuiSurface {
readonly runtime: TuiRuntime;
private scope: TuiScope | undefined;
private viewed: string | undefined;
- private ref: (() => Held) | undefined;
+ private ref: Held | undefined;
templates: readonly TemplateInfo[] = [];
skills: readonly SkillInfo[] = [];
private stop: () => void;
@@ -66,9 +66,8 @@ export class PromptSurface implements TuiSurface {
];
}
async expand(text: string): Promise {
- const ref = this.ref?.();
- if (ref === undefined || statusOf(ref) !== "live" || ref.variant !== "participant") return undefined;
- return (await ref.expand({ text }, BACKGROUND_CONTEXT)).text;
+ const ref = liveIn(this.ref, "participant");
+ return ref && (await ref.expand({ text }, BACKGROUND_CONTEXT)).text;
}
render(): string[] {
return [];
diff --git a/apps/p2/plugins/prompt/worker.ts b/apps/p2/plugins/prompt/worker.ts
index c2fb32527..5f680bf24 100644
--- a/apps/p2/plugins/prompt/worker.ts
+++ b/apps/p2/plugins/prompt/worker.ts
@@ -11,14 +11,13 @@ import {
} from "@earendil-works/pi-durable";
import type { ExecutionEnv } from "@earendil-works/pi-durable/env";
import { RemoteExecutionEnv } from "@earendil-works/pi-env";
-import { arr, num, obj, opt, str } from "@earendil-works/pi-schema";
+import { num, obj, opt, str } from "@earendil-works/pi-schema";
import {
BACKGROUND_CONTEXT,
type Context,
canSend,
defineFacet,
defineRegistry,
- defineTypedState,
deriveTyped,
forbidden,
local,
@@ -26,6 +25,8 @@ import {
nativeOf,
type Subscribable,
serveNative,
+ signalOf,
+ untilAborted,
} from "@p2/app/v1";
import { remotePaths } from "@p2/app/v1/node";
import {
@@ -38,7 +39,7 @@ import {
workerOf,
} from "@p2/app/v1/worker";
import type { LoadedPrompt } from "./context-files.ts";
-import { PromptResources, SkillInfoSchema, TemplateInfoSchema } from "./contract.ts";
+import { PromptResources, type SkillInfo, type TemplateInfo } from "./contract.ts";
import { envFiles, promptObserver } from "./environment.ts";
import { expandPrompt } from "./templates.ts";
@@ -75,7 +76,7 @@ type PromptLoads = {
/** Where conversations find their prompt resources: the session's (following its moves) or their own location's. */
type PromptFiles = {
- readonly session: PromptLoads;
+ readonly session: MovingPromptLoads;
of: (id: ConversationId, context: Context, read?: DocumentReader) => Promise;
};
@@ -140,11 +141,20 @@ function promptLoads(dataDir: string, at: Location, environment: Promise;
+};
+
/**
- * The prompt resources of the session's current location: loaded for the location it is at, and loaded anew (from the
- * new cwd, on the new machine) after each move. `loads` follows the current location's loads.
+ * The prompt resources of the session's current location: loaded for the location it is at, and loaded anew after
+ * each move. `loads` follows the current location's loads.
*/
-function followedPromptLoads(location: SessionLocation, open: (at: Location) => PromptLoads): PromptLoads {
+function movingPromptLoads(location: SessionLocation, open: (at: Location) => PromptLoads): MovingPromptLoads {
const listeners = new Set<(loaded: Loaded) => void>();
let latest: Loaded | undefined;
const forward = (loaded: Loaded): void => {
@@ -157,15 +167,9 @@ function followedPromptLoads(location: SessionLocation, open: (at: Location) =>
loads.loads.subscribe((loaded) => {
if (inner === loads) forward(loaded);
});
+ let version = location.version;
let inner = open(location.value);
let unsubscribe = follow(inner);
- const stop = location.subscribe((at) => {
- const previous = inner;
- unsubscribe();
- inner = open(at);
- unsubscribe = follow(inner);
- void previous.close().catch((error: unknown) => console.error("p2 prompt: closing old watches:", error));
- });
return {
current: () => inner.current(),
started: () => inner.started(),
@@ -177,8 +181,16 @@ function followedPromptLoads(location: SessionLocation, open: (at: Location) =>
return () => listeners.delete(listener);
},
},
+ move: async (at, moved) => {
+ if (moved <= version) return;
+ version = moved;
+ const previous = inner;
+ unsubscribe();
+ inner = open(at);
+ unsubscribe = follow(inner);
+ await previous.close().catch((error: unknown) => console.error("p2 prompt: closing old watches:", error));
+ },
close: async () => {
- stop();
unsubscribe();
await inner.close();
},
@@ -199,12 +211,18 @@ const Prompt = local("p2.prompt", {
/** The open `Prompt`'s files (the native it contributes), for `PromptResources`. */
const PromptFilesNative = defineRegistry("p2.prompt-files", { version: 1 });
-/** The templates and skills where one conversation works, as `PromptResources` shows them. */
-const Listed = defineTypedState("p2.prompt-resources", {
- schema: obj({ templates: arr(TemplateInfoSchema), skills: arr(SkillInfoSchema) }),
- version: 1,
- initial: () => ({ templates: [], skills: [] }),
-});
+/** The templates and skills of a load, as `PromptResources` shows them. */
+function listedOf(loaded: LoadedPrompt): { templates: TemplateInfo[]; skills: SkillInfo[] } {
+ return {
+ templates: loaded.templates.map(({ name, description, argumentHint, source }) => ({
+ name,
+ description,
+ ...(argumentHint ? { argumentHint } : {}),
+ source,
+ })),
+ skills: loaded.skillFiles.map(({ name, description }) => ({ name, description })),
+ };
+}
export default defineFacet((host) => {
serveNative(host, PromptFilesNative);
@@ -216,7 +234,7 @@ export default defineFacet((host) => {
const environments = harness().environments;
const dataDir = worker().init.dataDir;
const open = (at: Location): PromptLoads => promptLoads(dataDir, at, environments.at(at));
- const session = followedPromptLoads(worker().location, open);
+ const session = movingPromptLoads(worker().location, open);
const own = new Map();
const files: PromptFiles = {
session,
@@ -241,13 +259,29 @@ export default defineFacet((host) => {
},
(value) => value.close(),
);
- self.open(
- async () =>
- filesResource.value.files.session.loads.subscribe(({ error }) => {
- if (error !== undefined) console.error(`p2 prompt: ${error}`);
- }),
- (stop) => stop(),
- );
+ // The session's resources follow its moves (one committed while the files opened included).
+ self.onLive(async (ctx) => {
+ const location = worker().location;
+ const session = filesResource.value.files.session;
+ for (;;) {
+ // Listening before moving: a move committed while the old watches close is not missed.
+ const moved = location.next(signalOf(ctx));
+ moved.catch(() => {}); // The abort ends this routine below.
+ await session.move(location.value, location.version);
+ await moved;
+ }
+ });
+ // Load failures of the session's resources go to the worker's log (they also leave the last good load in use).
+ self.onLive(async (ctx) => {
+ const stop = filesResource.value.files.session.loads.subscribe(({ error }) => {
+ if (error !== undefined) console.error(`p2 prompt: ${error}`);
+ });
+ try {
+ await untilAborted(ctx);
+ } finally {
+ stop();
+ }
+ });
self.contribute(PromptFilesNative, {
get session() {
return filesResource.value.files.session;
@@ -312,31 +346,18 @@ export default defineFacet((host) => {
if (!canSend(principal, session)) throw forbidden("participants only");
const prompt = self.get(Prompt, {});
const files = nativeOf(self, PromptFilesNative, "The prompt files");
- const listed = self.state(Listed);
- let fingerprint: string | undefined;
- const show = (loaded: LoadedPrompt, context: Context) => {
- fingerprint = loaded.fingerprint;
- void listed
- .change((draft) => {
- draft.templates = loaded.templates.map(({ name, description, argumentHint, source }) => ({
- name,
- description,
- ...(argumentHint ? { argumentHint } : {}),
- source,
- }));
- draft.skills = loaded.skillFiles.map(({ name, description }) => ({ name, description }));
- }, context)
- .catch(() => {});
- };
- self.open(
- async (ctx) => {
- const loads = await files().of(conversation as ConversationId, ctx);
- return loads.loads.subscribe(({ prompt: loaded }) => {
- if (loaded && loaded.fingerprint !== fingerprint) show(loaded, BACKGROUND_CONTEXT);
- });
- },
- (stop) => stop(),
- );
+ // The loads where the conversation works, followed: each new load (a changed fingerprint) is the next value.
+ const listed = self.follow(async (observer, ctx) => {
+ const loads = await files().of(conversation as ConversationId, ctx);
+ let fingerprint = loads.current().fingerprint;
+ const stop = loads.loads.subscribe(({ prompt: loaded }) => {
+ if (!loaded || loaded.fingerprint === fingerprint) return;
+ fingerprint = loaded.fingerprint;
+ const value = listedOf(loaded);
+ observer.advance({ kind: "value", value, ops: [["r", value]], retired: false }, BACKGROUND_CONTEXT);
+ });
+ return { value: listedOf(loads.current()), detach: stop };
+ });
return {
variant: "participant",
members: {
diff --git a/apps/p2/plugins/search/contract.ts b/apps/p2/plugins/search/contract.ts
index 2572b4939..5d9dafccb 100644
--- a/apps/p2/plugins/search/contract.ts
+++ b/apps/p2/plugins/search/contract.ts
@@ -1,5 +1,16 @@
import { arr, bool, int, nul, num, obj, opt, type Static, str, union } from "@earendil-works/pi-schema";
-import { literal, local, method, param, propertiesOf, record, remote, serverHost, sessionHost } from "@p2/app/v1";
+import {
+ evolve,
+ literal,
+ local,
+ method,
+ param,
+ propertiesOf,
+ record,
+ remote,
+ serverHost,
+ sessionHost,
+} from "@p2/app/v1";
/** One committed user or assistant message of a session conversation, as indexed. */
export const IndexedMessageSchema = obj({
@@ -66,6 +77,13 @@ const HistoryQuerySchema = obj({
limit: int({ minimum: 1, maximum: 20 }),
});
+/** A session's index rebuild: running, or failed with why (kept until the next rebuild of that session). */
+export const RebuildSchema = union([
+ obj({ session: str(), phase: literal("running") }),
+ obj({ session: str(), phase: literal("failed"), error: str() }),
+]);
+export type Rebuild = Static;
+
/** Root conversation entry IDs by session. */
const EntryIdsSchema = record(num());
@@ -92,6 +110,10 @@ export const SearchIndex = local("p2.search-index", {
history: method(obj({ session: str(), ...propertiesOf(HistoryQuerySchema) }), HistoryHitsSchema),
/** Message matches among `sessions` (literal terms, all required). */
messages: method(obj({ query: str(), sessions: arr(str()) }), arr(MessageHitSchema)),
+ /** The index rebuilds running or failed (a job each: they survive a server restart). */
+ rebuilds: arr(RebuildSchema),
+ /** Starts re-indexing `session` from the start (`Search.rebuildIndex`); refuses an unknown session. */
+ rebuild: method(obj({ session: str() })),
},
});
@@ -116,8 +138,8 @@ export const SearchFeed = remote("p2.search-feed", {
},
});
-/** Search over session titles, working directories and message text (owner only). */
-export const Search = remote("p2.search", {
+/** Search over session titles, working directories and message text (owner only): the base of the history. */
+const SearchV1 = remote("p2.search", {
version: 1,
params: {},
hostAddress: serverHost,
@@ -128,7 +150,7 @@ export const Search = remote("p2.search", {
/**
* Re-indexes one session from the start (Pi Mobile's `rebuildIndex`): its worker stops, its index entries
* go, and a fresh worker scans its history; an archived session's worker scans without resuming work and
- * stops again. Returns once the scan committed.
+ * stops again.
*/
rebuildIndex: method(obj({ session: str() })),
},
@@ -136,6 +158,12 @@ export const Search = remote("p2.search", {
},
});
+/**
+ * Search over session titles, working directories and message text (owner only). 2: `rebuildIndex` answers once the
+ * rebuild started (a job); `rebuilds` shows it running, and why it failed. The provider still serves 1.
+ */
+export const Search = evolve(SearchV1, (e) => e.addState("owner", "rebuilds", arr(RebuildSchema)));
+
/**
* Unread answers (owners only): a session is unread while its latest root answer (`answers`) is newer than what an owner
* has read (`reads`), on any device. A browser marks a session read when its root conversation is shown.
diff --git a/apps/p2/plugins/search/server.ts b/apps/p2/plugins/search/server.ts
index 6c1bf10a7..e2ec17130 100644
--- a/apps/p2/plugins/search/server.ts
+++ b/apps/p2/plugins/search/server.ts
@@ -1,10 +1,10 @@
// The message index, the workers' feed into it, and owner search over sessions and messages.
-import { num, obj } from "@earendil-works/pi-schema";
-import { type Ctx, defineFacet, defineState, forbidden, isOwner, isWorker, record } from "@p2/app/v1";
-import { Hosts, Server, serverOf } from "@p2/app/v1/server";
+import { arr, num, obj, str } from "@earendil-works/pi-schema";
+import { defineFacet, defineJob, defineState, deriveTyped, forbidden, isOwner, isWorker, record } from "@p2/app/v1";
+import { Server, serverOf } from "@p2/app/v1/server";
import { ArchivedStarts, Catalog, type Session } from "../sessions/contract.ts";
-import { IndexScan, Search, SearchFeed, SearchIndex, SessionReads } from "./contract.ts";
+import { IndexScan, type Rebuild, RebuildSchema, Search, SearchFeed, SearchIndex, SessionReads } from "./contract.ts";
import { SearchDb } from "./db.ts";
import { ftsQuery } from "./query.ts";
@@ -19,11 +19,29 @@ const Markers = defineState("p2.search-index", {
initial: () => ({ answers: {}, reads: {} }),
});
+/** Pi Mobile's rebuildIndex as a job: a rebuild interrupted by a restart runs again (it starts over). */
+const RebuildJob = defineJob("p2.search.rebuild", {
+ version: 1,
+ args: obj({ session: str() }),
+ result: obj({}),
+ persist: { resume: "rerun" },
+});
+
+/** The latest failed rebuild per session, until it is rebuilt again. */
+const RebuildFailures = defineState("p2.search.rebuild-failures", {
+ schema: obj({ list: arr(RebuildSchema) }),
+ version: 1,
+ initial: () => ({ list: [] }),
+});
+
export default defineFacet((host) => {
host.serve(SearchIndex, (_params, self) => {
const server = self.get(Server, {});
const catalog = self.get(Catalog, {});
+ const archivedStarts = self.get(ArchivedStarts, {});
+ const natives = serverOf(self);
const markers = self.state(Markers);
+ const failures = self.state(RebuildFailures);
const dbResource = self.open(
async (ctx) => {
const db = new SearchDb(server.info.value.dataDir);
@@ -35,7 +53,48 @@ export default defineFacet((host) => {
},
(db) => db.close(),
);
+ // Stop the worker, reset the index, scan from a fresh worker (an archived one without resuming its work), and stop
+ // an archived session's worker again. This local object's `ctx.get` acts as the server, whom `IndexScan` serves.
+ const rebuilds = self.job(RebuildJob, {
+ run: async ({ session }, ctx) => {
+ const archived = (await catalog.get({ id: session }, ctx)).archivedAt !== null;
+ await natives().workers.stop(session);
+ dbResource.value.reset(session);
+ if (archived) await archivedStarts.allow({ session }, ctx);
+ try {
+ await (await ctx.get(IndexScan, { session })).scanned(ctx);
+ } finally {
+ if (archived) {
+ await archivedStarts.end({ session }, ctx);
+ await natives().workers.suspend(session);
+ }
+ }
+ return {};
+ },
+ finish: async (outcome, tx) => {
+ const draft = await tx.draft(failures);
+ draft.list = draft.list.filter((each) => each.session !== outcome.args.session);
+ if (outcome.kind === "failed")
+ draft.list.push({
+ session: outcome.args.session,
+ phase: "failed",
+ error: outcome.error.kind === "threw" ? outcome.error.error.message : "The rebuild was interrupted",
+ });
+ },
+ });
return {
+ rebuilds: deriveTyped(self, [rebuilds.running, failures], ([running, { list }]): Rebuild[] => [
+ ...running.flatMap((run) =>
+ run.phase === "blocked" ? [] : [{ session: run.args.session, phase: "running" as const }],
+ ),
+ ...list,
+ ]),
+ rebuild: async ({ session }, ctx) => {
+ // Refused at once for a session the catalog does not know.
+ await catalog.get({ id: session }, ctx);
+ await rebuilds.start({ session }, ctx, { key: session });
+ return null;
+ },
answers: self.derive([markers], ([value]) => value.answers),
reads: self.derive([markers], ([value]) => value.reads),
markRead: async ({ session, through }, ctx) => {
@@ -121,55 +180,36 @@ export default defineFacet((host) => {
};
});
- host.serve(Search, (_params, principal, self) => {
- if (!isOwner(principal)) throw forbidden("search is owner-only");
- const catalog = self.get(Catalog, {});
- const index = self.get(SearchIndex, {});
- const hosts = self.get(Hosts, {});
- const archivedStarts = self.get(ArchivedStarts, {});
- const server = serverOf(self);
- // Pi Mobile's rebuildIndex: stop the worker, reset the index, scan from a fresh worker (an archived one without
- // resuming its work), and stop an archived session's worker again.
- const rebuild = async (session: string, ctx: Ctx) => {
- const archived = (await catalog.get({ id: session }, ctx)).archivedAt !== null;
- await hosts.stop({ session }, ctx);
- await index.reset({ session }, ctx);
- if (archived) await archivedStarts.allow({ session }, ctx);
- try {
- // As the server: the worker answers only the server's own scan requests.
- await server().root(async (root) => {
- const scan = await root.get(IndexScan, { session });
- await scan.scanned(root);
- });
- } finally {
- if (archived) {
- await archivedStarts.end({ session }, ctx);
- await server().workers.suspend(session);
- }
- }
- };
- return {
- variant: "owner",
- members: {
- rebuildIndex: async ({ session }, ctx) => {
- await rebuild(session, ctx);
- return null;
+ host.serve(
+ Search,
+ (_params, principal, self) => {
+ if (!isOwner(principal)) throw forbidden("search is owner-only");
+ const catalog = self.get(Catalog, {});
+ const index = self.get(SearchIndex, {});
+ return {
+ variant: "owner",
+ members: {
+ rebuilds: index.rebuilds,
+ rebuildIndex: (args, ctx) => index.rebuild(args, ctx),
+ search: async ({ query, archived = false }, ctx) => {
+ const scope = catalog.sessions.value.filter((session) => (session.archivedAt !== null) === archived);
+ const needle = query.toLowerCase();
+ const sessions = scope
+ .filter((session) => `${session.title}\n${session.cwd}`.toLowerCase().includes(needle))
+ .sort(sidebarOrder)
+ .map((session) => session.id);
+ const titles = new Map(scope.map((session) => [session.id, session.title]));
+ const messages = query.trim()
+ ? await index.messages({ query, sessions: [...titles.keys()] }, ctx)
+ : [];
+ return {
+ sessions,
+ messages: messages.map((hit) => ({ ...hit, title: titles.get(hit.session) ?? "" })),
+ };
+ },
},
- search: async ({ query, archived = false }, ctx) => {
- const scope = catalog.sessions.value.filter((session) => (session.archivedAt !== null) === archived);
- const needle = query.toLowerCase();
- const sessions = scope
- .filter((session) => `${session.title}\n${session.cwd}`.toLowerCase().includes(needle))
- .sort(sidebarOrder)
- .map((session) => session.id);
- const titles = new Map(scope.map((session) => [session.id, session.title]));
- const messages = query.trim() ? await index.messages({ query, sessions: [...titles.keys()] }, ctx) : [];
- return {
- sessions,
- messages: messages.map((hit) => ({ ...hit, title: titles.get(hit.session) ?? "" })),
- };
- },
- },
- };
- });
+ };
+ },
+ { accepts: { 1: {} } },
+ );
});
diff --git a/apps/p2/plugins/search/tui.ts b/apps/p2/plugins/search/tui.ts
index f981d4240..481d3a3fc 100644
--- a/apps/p2/plugins/search/tui.ts
+++ b/apps/p2/plugins/search/tui.ts
@@ -1,8 +1,15 @@
// The session selector's message search in the TUI (p2-tui-port.md section 1.17): `Search` is literal and bounded, and
-// the owner's; the selector shows its hits as a separate group.
+// the owner's; the selector shows its hits as a separate group. `/reindex` rebuilds the viewed session's index.
-import { BACKGROUND_CONTEXT, contribute, defineFacet } from "@p2/app/v1";
-import { type TuiSessionSource, TuiSessionSources } from "@p2/app/v1/tui";
+import { BACKGROUND_CONTEXT, contribute, defineFacet, liveIn, whenLive } from "@p2/app/v1";
+import {
+ lineText,
+ type TuiCommand,
+ TuiCommands,
+ type TuiContext,
+ type TuiSessionSource,
+ TuiSessionSources,
+} from "@p2/app/v1/tui";
import { Search } from "./contract.ts";
const source: TuiSessionSource = {
@@ -14,8 +21,52 @@ const source: TuiSessionSource = {
}, BACKGROUND_CONTEXT),
};
+/**
+ * `/reindex`: re-indexes the viewed session's history (`Search.rebuildIndex`). Long work (a job on the server): a
+ * line says it runs at once, another when it is done or why it failed (`Search.rebuilds`).
+ */
+function reindex(ctx: TuiContext): void {
+ const session = ctx.identity.session;
+ if (!session) return;
+ const scope = ctx.scope();
+ let started = false;
+ const search = scope.watch(Search, {}, (ref, follow) => {
+ if (ref.variant !== "owner") return;
+ follow(ref.rebuilds, (rebuilds) => {
+ const rebuild = rebuilds.find((each) => each.session === session);
+ if (!started || rebuild?.phase === "running") return;
+ if (rebuild?.phase === "failed")
+ ctx.notify(`Search index rebuild failed: ${lineText(rebuild.error)}`, "error");
+ else ctx.notify("Search index rebuilt");
+ scope.dispose();
+ });
+ });
+ ctx.notify("Rebuilding the search index…");
+ void (async () => {
+ await whenLive(search);
+ const owner = liveIn(search, "owner");
+ if (!owner) throw new Error("Only the owner rebuilds the search index");
+ await owner.rebuildIndex({ session }, BACKGROUND_CONTEXT);
+ started = true;
+ })().catch((error: unknown) => {
+ ctx.fail(error);
+ scope.dispose();
+ });
+}
+
+const COMMANDS: Record = {
+ "p2.search.reindex": {
+ title: "Rebuild the session's search index",
+ category: "Owner",
+ when: "chat && owner && !overlay",
+ slash: { name: "reindex" },
+ run: (ctx) => reindex(ctx),
+ },
+};
+
export default defineFacet((host) =>
contribute(host, "p2.tui-search", (self) => {
self.contribute(TuiSessionSources, { search: source });
+ self.contribute(TuiCommands, COMMANDS);
}),
);
diff --git a/apps/p2/plugins/search/worker.ts b/apps/p2/plugins/search/worker.ts
index 1b79cc5ba..7bcb30ad1 100644
--- a/apps/p2/plugins/search/worker.ts
+++ b/apps/p2/plugins/search/worker.ts
@@ -24,7 +24,6 @@ import {
signalOf,
until,
} from "@p2/app/v1";
-import { durableContext } from "@p2/app/v1/node";
import { CommitSignal, harnessOf, topLevelRecords } from "@p2/app/v1/worker";
import { Subagents } from "../subagents/contract.worker.ts";
import { type IndexedMessage, IndexScan, SearchFeed } from "./contract.ts";
@@ -89,17 +88,11 @@ async function catchUp(
through: number,
ctx: Ctx,
): Promise {
- const context = durableContext(ctx);
- const upper = (await root.entries({}, 1, undefined, context)).items[0]?.id;
+ const upper = (await root.entries({}, 1, undefined, ctx)).items[0]?.id;
if (upper === undefined || upper <= through) return through;
let cursor: Cursor | undefined;
do {
- const page = await root.entries(
- { minEntryId: (through + 1) as EntryId, maxEntryId: upper },
- PAGE,
- cursor,
- context,
- );
+ const page = await root.entries({ minEntryId: (through + 1) as EntryId, maxEntryId: upper }, PAGE, cursor, ctx);
cursor = page.next;
const messages = page.items.map(indexedMessage).filter((message) => message !== undefined);
await feed.index({ conversation: root.id, messages, through: cursor === undefined ? upper : through }, ctx);
@@ -137,16 +130,15 @@ export default defineFacet((host) => {
while (!signalOf(ctx).aborted) {
const seen = signal.version;
try {
- const context = durableContext(ctx);
- const registry = await durable.snapshot(Subagents, ROOT_CONVERSATION_ID, context);
+ const registry = await durable.snapshot(Subagents, ROOT_CONVERSATION_ID, ctx);
// A branch's history starts after its fork point: what it inherits is indexed with the conversation
// storing it.
const starts = new Map(
- (await topLevelRecords(durable, context)).map((record) => [record.id, record.parent?.at ?? 0]),
+ (await topLevelRecords(durable, ctx)).map((record) => [record.id, record.parent?.at ?? 0]),
);
for (const agent of Object.values(registry?.agents ?? {})) starts.set(agent.conversationId, 0);
for (const [id, start] of starts) {
- const conversation = await durable.conversation(id as ConversationId, context);
+ const conversation = await durable.conversation(id as ConversationId, ctx);
if (!conversation) continue;
const through = await feed.cursor({ conversation: id }, ctx);
await catchUp(conversation, feed, Math.max(through, start), ctx);
diff --git a/apps/p2/plugins/sessions/BranchSheet.tsx b/apps/p2/plugins/sessions/BranchSheet.tsx
index 931b7d01b..e156ada7f 100644
--- a/apps/p2/plugins/sessions/BranchSheet.tsx
+++ b/apps/p2/plugins/sessions/BranchSheet.tsx
@@ -3,7 +3,10 @@ import { Sheet } from "@p2/app/v1/web";
import { useState } from "react";
import { PREVIEW_LENGTH } from "../tree/contract.ts";
-/** Renames a branch; `label` null shows `title` (conversation 1's default), and only conversation 1 may go back to it. */
+/**
+ * Renames a branch; `label` null shows `title` (conversation 1's default), and only conversation 1 may go back to it.
+ * Saving closes the sheet: the row shows the new label while the rename is in flight.
+ */
export function BranchSheet({
title,
conversation,
@@ -15,18 +18,17 @@ export function BranchSheet({
title: string;
conversation: number;
label: string | null;
- onSave: (label: string | null) => Promise;
+ onSave: (label: string | null) => void;
onClose: () => void;
}) {
const [draft, setDraft] = useState(label ?? title);
- const [error, setError] = useState();
const next = draft.replace(/\s+/g, " ").trim();
const resettable = conversation === 1;
const unchanged = next === (label ?? title);
- const save = (value: string | null) =>
- void onSave(value)
- .then(onClose)
- .catch((failure: unknown) => setError(failure instanceof Error ? failure.message : String(failure)));
+ const save = (value: string | null) => {
+ onSave(value);
+ onClose();
+ };
return (