feat(p2): Chord v2 cleanup pass

p2 rewritten by the Chord v2 authoring rules (decision "App authoring rules confirmed
by the p2 audit"), in four parts merged together:
- core: session lifecycle (relocate, clone, fork, archive) and search rebuild are
  persisted jobs on a new server store; Sessions v6 with client-chosen ids and visible
  operations; Retry answers with the worker's real outcome; workers push branch,
  subagent and status state to the server; roots take the method's ctx.
- services: models, envs, ports, login, mcp, logs, sharing and voice follow external
  state (self.follow, followNative) instead of copy loops; methods answer after their
  state commits; presence from host.references.
- chat: chat mode, artifacts and prompt resources follow Durable and native state;
  shell commands are Chord jobs (shellId is the requestId); status pushed before
  send and stop answer; race-free draft claim.
- ui: optimistic updates on web (useOptimistic) and terminal (TuiScope.optimistic,
  extended in Chord with a reducer and a pending flag), pending states for long work,
  visible rollback, load waterfalls removed; merged with the one composer and
  immediate first send (ccff9876e) and the folder + button (5a31a6d5f).
docs/optimistic-ui.md describes the pattern.
This commit is contained in:
Mario Zechner 2026-11-05 18:07:24 +00:00
parent 1137075c78
commit 20d86c3657
154 changed files with 4996 additions and 3002 deletions

View file

@ -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 |

View file

@ -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/<server>` begins (or replaces) the server's flow, so the link is

View file

@ -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<Shown, Partial<Shown>>(
{ enabled: useReadable(mode?.enabled) ?? false, name: useReadable(mode?.name) ?? DEFAULT_AGENT_NAME },
(shown, change) => ({ ...shown, ...change }),
fail,
);
// ...
<button onClick={() => 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/<id>` 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).

View file

@ -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 `<data>/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:<name>` 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

View file

@ -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`):

View file

@ -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

View file

@ -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

View file

@ -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) } };
});

View file

@ -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,

View file

@ -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<typeof ChatMode> | undefined = () => undefined;
enabled = false;
private mode: Held<typeof ChatMode> | 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<boolean> | 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<typeof ChatMode> | undefined {
const ref = this.mode();
return liveIn(ref, "owner");
owner(): HeldIn<typeof ChatMode, "owner"> | 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<string, TuiCommand> = {
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": {

View file

@ -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<unknown>;
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<string>();
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 (
<Sheet title="Agent name" onClose={onClose}>
@ -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 _.
</p>
{error && <p className="error small">{error}</p>}
<div className="inline-form">
<button type="submit" className="button" disabled={!valid || draft.trim() === name}>
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<Shown, Partial<Shown>>(
{ 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}
</button>
@ -142,7 +147,7 @@ function ChatModeChip() {
{renaming && (
<NameSheet
name={name}
onSave={(next) => mode.rename({ name: next }, BACKGROUND_CONTEXT)}
onSave={(next) => guess({ name: next }, () => mode.rename({ name: next }, BACKGROUND_CONTEXT))}
onClose={() => setRenaming(false)}
/>
)}

View file

@ -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));

View file

@ -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<typeof LiveShellSchema>;
/** The conversation's user shell command; null when none. */
export const ShellStateSchema = union([LiveShellSchema, nul()]);
export type ShellState = Static<typeof ShellStateSchema>;
/** 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<typeof SubmitSchema>;
* 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() })),
},
});

View file

@ -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<string>;
/** Creates the session `start.session` (again, after a lost answer: the server answers the same row). */
create(start: Start): Promise<unknown>;
/** 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<Start, "messages">): 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 ?? [];

View file

@ -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<typeof CommandSchema>;
/**
* 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<typeof OutputSchema>;
/** 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<P2Principal, Output>;
/** 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<string, LiveShell> = {};
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<void> {
let output = "";
let truncated = false;
let timer: ReturnType<typeof setTimeout> | undefined;
// State changes are commits: chained, so the last one is the last written, and awaited before the run moves on.
let writes: Promise<void> = 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<Output, "exitCode" | "fullOutputPath" | "error"> = {};
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<void> {
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);
}

View file

@ -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<typeof Chat>) | undefined;
private ref: Held<typeof Chat> | 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<Outgoing> | undefined;
/** A `!cmd` in flight: its block shows as running before the server's state does. */
private starting: () => ShellState = () => null;
private start: OptimisticAdd<ShellState> | undefined;
/** A `/compact` in flight: the compaction indicator shows before `pi.live` does. */
private compacting: () => boolean = () => false;
private compactNow: OptimisticAdd<boolean> | undefined;
/** The session catalog (`/fork`, `/clone`). */
private catalog: Held<typeof Sessions>;
/**
* 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<typeof SessionTree> } | 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<typeof FileIndex>) | undefined;
private files: Held<typeof FileIndex> | 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<typeof Chat, "participant"> | 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<void>) =>
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<unknown>, 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();
}
}

View file

@ -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<typeof useCompose
remove: (file) => 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)}
</button>
) : (
<p>{locationLabel(session.env, session.cwd)}</p>
@ -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<unknown> | undefined) => {
void action?.catch((reason: unknown) => setError(errorText(reason)));
},
[setError],
);
const perform = useCallback((action: Promise<unknown> | 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<ModelChoice | undefined, ModelChoice>(
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 ? (
<ModelControl resources={resources} {...(choice ? { choice } : {})} />
) : (
@ -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" : ""}
</span>
{canWrite && !oneShot && (
{canWrite && !oneShot && subagentControl && (
<button
type="button"
className="text-action"
title={
child.archived
? "Show it in the sidebar again"
: "Stop it and move it to the sidebar's archived row"
archived ? "Show it in the sidebar again" : "Stop it and move it to the sidebar's archived row"
}
onClick={() =>
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"}
</button>
)}
{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);
})
}
>
<span>{item.mode === "steer" ? "Steer" : "Queued"}</span> {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))
}
>
<CloseIcon />
</button>
@ -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 (
<>
<TopBar session={session} child={child} owner={owner} />
<TopBar session={session} child={child} owner={owner} moving={moving} />
{cwdSheet && owner && session && (
<DirectoryPicker
value={session.cwd}
@ -1250,7 +1316,8 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers
]}
onClose={closeLayers}
onChange={(env, cwd) => {
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
<ModelPicker
resources={resources}
value={choice}
onChange={(choice) => 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 ? (
<div className="actions">
<button type="button" className="button" disabled={retrying} onClick={() => void retry()}>
Retry
{retrying ? "Retrying…" : "Retry"}
</button>
<button
type="button"
@ -1373,13 +1438,13 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers
function NewSession() {
const connected = useConnected();
const host = useHost();
const owner = useOwner();
const catalog = useObject(Sessions, {});
const listed = useReadable(liveIn(catalog, "owner", "guest")?.sessions);
// Only the owner creates sessions.
const sessions = liveIn(catalog, "owner");
const models = useObject(Models, owner ? {} : undefined);
const resources = useReadable(models?.resources);
const [resources, setDefault] = useModelResources(owner, useComposerState().fail);
const recent = useMemo(() => (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<HTMLTextAreaElement>(null);
const frame = useMemo(() => ({ view, dock, contents, composer }), [view, dock, contents]);
const conversation = route.session && `${route.session}:${route.conversation ?? 1}`;
// A short link (`/s/<id>`) 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 (
<ChatFrame.Provider value={frame}>
{ended ? (
@ -1605,7 +1683,7 @@ function Main() {
) : route.session ? (
<Slot name="chat-content">
<Slot name="chat-attribution">
<SessionChat key={conversation} sessionId={route.session} conversationId={route.conversation ?? 1} />
<SessionChat key={conversation} sessionId={route.session} conversationId={shownConversation} />
</Slot>
</Slot>
) : (

View file

@ -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 = `<attachments>\nThe user attached these files:\n${list}\n</attachments>`;
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 = `<attachments>\nThe user attached these files:\n${list}\n</attachments>`;
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<typeof live.value.stats, object>, 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<void> => {
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<void>((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<typeof durableContext>) => {
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<string, string>();
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<void> => {
let live: Extract<ShellState, object> = {
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<typeof setTimeout> | undefined;
const publish = () => {
timer = undefined;
if (shell.value?.id !== shellId) return;
live = { ...live, output, truncated };
void setShell(live, ctx);
};
let ended: Pick<ShellData, "exitCode" | "fullOutputPath" | "error"> = {};
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 = <T>(fn: () => Promise<T>): Promise<T> => 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<void> => {
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(
{

View file

@ -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<void> {
const context = BACKGROUND_CONTEXT;
async cancel(harness: DurableHarness, context: Context): Promise<void> {
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<void> {
const deadline = Date.now() + REPORT_MS;
while (Date.now() < deadline) {
const live = await harness.snapshot(LiveDoc, api.conversationId, context);
const done = (live: Readonly<LiveState> | 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<void>();
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();
}
}

View file

@ -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.

View file

@ -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);
},

View file

@ -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 (
<OpenPeerConversation.Provider value={owner && connected ? open : undefined}>

View file

@ -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 };
}),
},

View file

@ -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() };

View file

@ -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 };
},
};
});

View file

@ -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<EnvTest>();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string>();
// 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<string | null | undefined>(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<Draft>) =>
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<unknown> | 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"}
</button>
@ -468,12 +505,12 @@ export function EnvsPanel({ env: screen, onClose }: { env?: string; onClose: ()
<button
type="button"
className="text-action danger"
onClick={() =>
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
</button>

View file

@ -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;

View file

@ -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);
}
}

View file

@ -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) => {

View file

@ -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;
});

View file

@ -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<HistoryHits>;
export type HistorySearch = (
query: {
query: string;
conversation: number;
before?: number;
limit: number;
},
context: Context,
) => Promise<HistoryHits>;
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 }[] = [];

View file

@ -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 });

View file

@ -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),

View file

@ -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() })),
},

View file

@ -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<void>;
};
/** 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<void> {
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<void> {
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 }),
};
}
}

View file

@ -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,

View file

@ -422,10 +422,11 @@ async function logout(ctx: TuiContext, name: string | undefined): Promise<void>
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) {

View file

@ -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<typeof ProviderLogin> }) {
/** 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<string>();
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<typeof Pr
<p>{prompt.message}</p>
<div className="list">
{prompt.options.map((option) => (
<button
type="button"
key={option.id}
className="row-button"
disabled={sending}
onClick={() => answer(option.id)}
>
<button type="button" key={option.id} className="row-button" onClick={() => answer(option.id)}>
<span>{option.label}</span>
<ChevronIcon />
</button>
))}
</div>
{error && <p className="error small">{error}</p>}
</div>
);
return (
@ -140,8 +125,7 @@ function PromptForm({ state, login }: { state: LoginState; login: Held<typeof Pr
value={value}
onChange={(event) => setValue(event.target.value)}
/>
{error && <p className="error small">{error}</p>}
<button type="submit" className="login-continue" disabled={sending}>
<button type="submit" className="login-continue">
<span>Continue</span>
<ChevronIcon />
</button>
@ -152,8 +136,22 @@ function PromptForm({ state, login }: { state: LoginState; login: Held<typeof Pr
/** The provider list, one provider's actions, or the running login. Closing the sheet cancels an unfinished one. */
function ProvidersSheet({ onClose }: { onClose: () => 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<string>();
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<unknown> | 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) => (
<Notice key={entry.id} event={entry.event} />
))}
{state.prompt && <PromptForm key={state.prompt.id} state={state} login={login} />}
{state.prompt && (
<PromptForm
key={state.prompt.id}
state={state}
onAnswer={(promptId, value) =>
guessLogin({ ...state, prompt: null }, () =>
login.answer({ attemptId: state.attemptId, promptId, value }, BACKGROUND_CONTEXT),
)
}
/>
)}
{state.status === "working" && !state.prompt && !state.notices.length && (
<div className="spinner large" />
)}
@ -219,7 +222,13 @@ function ProvidersSheet({ onClose }: { onClose: () => void }) {
<button
type="button"
className="text-action"
onClick={() => 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
</button>
@ -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),
)
}
>
<span>{method.label}</span>
@ -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 }) {
<button
type="button"
className="login-action danger"
onClick={() => perform(login.logout({ providerId: provider.id }, BACKGROUND_CONTEXT))}
onClick={() =>
signOut(provider.id, () => login.logout({ providerId: provider.id }, BACKGROUND_CONTEXT))
}
>
<span>Sign out</span>
</button>

View file

@ -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: {

View file

@ -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<typeof LogNotices> | undefined = () => undefined;
notices: Notice[] = [];
private service: Held<typeof LogNotices> | 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<number> | undefined;
private received: Notice[] = [];
private seen = new Set<number>();
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<typeof LogNotices> | 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(

View file

@ -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 }) {
</div>
{message && <output className="notice">{message}</output>}
<pre ref={area} className="logs-output" title="Log lines">
{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…")}
</pre>
</Sheet>
);

View file

@ -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);
}
}

View file

@ -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<void> {
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,

View file

@ -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<OwnerServer[]>(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<OwnerServer[]>(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<typeof McpManager> {
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<typeof McpManager> {
),
(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<typeof McpManager> {
...managerMethods(leased, () => instances.value, placement),
...signInMethods(
configured,
listed,
() => signIns.value,
() => probe.value,
),
@ -217,14 +231,36 @@ function managerFactory(connector: EnvConnector): Factory<typeof McpManager> {
};
}
/**
* 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<typeof Memento>,
configured: Watchable<OwnerConfigState>,
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<typeof Catalog>,
store: Ref<typeof ChildStore>,
self: Self,
): Watchable<WorkEnvironments> {
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<Record<string, readonly Child[]>>,
): 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<OwnerConfigFile>,
instances: Native<Instances>,
signIns: Native<SignIns>,
configured: HandleOf<typeof Configured>,
listed: HandleOf<typeof Listed>,
ctx: Ctx,
): Promise<void> {
// 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<typeof Leased>, instances: () => Instances, placement: () => Placement) {
return {
@ -341,12 +331,7 @@ function managerMethods(leased: HandleOf<typeof Leased>, 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<typeof Configured>,
listed: HandleOf<typeof Listed>,
signIns: () => SignIns,
probe: () => LoopbackProbe,
) {
function signInMethods(configured: Watchable<OwnerConfigState>, 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

View file

@ -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 <url>]`: 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<unknown>) => {
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<unknown>, 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();
},
}),

View file

@ -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<typeof Mcp, "owner">;
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<Instance["status"], string> = {
starting: "Starting",
@ -32,10 +56,22 @@ const STATUS: Record<Instance["status"], string> = {
/** 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 (
<Sheet title="MCP servers" onClose={onClose}>
<p className="muted small">
@ -48,12 +84,13 @@ function McpSheet({ onClose }: { onClose: () => void }) {
</p>
))}
{view && servers.length === 0 && <p className="muted">No MCP server is running.</p>}
{view && (
{view && actions && (
<div className="list">
{servers.map((server) => (
<ServerEntry
key={server}
view={view}
actions={actions}
server={server}
instances={instances.filter((instance) => 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<unknown>) => 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({
</p>
{instance.error && instance.status !== "sign-in" && <p className="error small">{instance.error}</p>}
<div className="inline-form">
<button
type="button"
className="text-action"
onClick={() => perform(view.restart({ instance: instance.id }, BACKGROUND_CONTEXT))}
>
<button type="button" className="text-action" onClick={() => actions.restart(instance.id)}>
Restart
</button>
</div>
</div>
))}
{oauth && signIn?.status === "waiting" && <PendingSignIn view={view} signIn={signIn} />}
{oauth && signIn?.status === "waiting" && (
<PendingSignIn view={view} signIn={signIn} onCancel={() => actions.cancelSignIn(signIn.id)} />
)}
{oauth && signIn?.status === "failed" && (
<div className="stack">
<p className="error small">{signIn.error ?? "The sign-in failed."}</p>
<button
type="button"
className="text-action"
onClick={() => perform(view.cancelSignIn({ id: signIn.id }, BACKGROUND_CONTEXT))}
>
<button type="button" className="text-action" onClick={() => actions.cancelSignIn(signIn.id)}>
Dismiss
</button>
</div>
@ -132,11 +163,7 @@ function ServerEntry({
</a>
) : null}
{connected && (
<button
type="button"
className="text-action danger"
onClick={() => perform(view.signOut({ server }, BACKGROUND_CONTEXT))}
>
<button type="button" className="text-action danger" onClick={() => actions.signOut(server)}>
Sign out
</button>
)}
@ -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<string>();
@ -184,11 +211,7 @@ function PendingSignIn({ view, signIn }: { view: OwnerView; signIn: SignIn }) {
<button type="submit" className="button" disabled={sending || !value.trim()}>
Continue
</button>
<button
type="button"
className="text-action"
onClick={() => void view.cancelSignIn({ id: signIn.id }, BACKGROUND_CONTEXT).catch(() => {})}
>
<button type="button" className="text-action" onClick={onCancel}>
Cancel
</button>
</div>

View file

@ -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(

View file

@ -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<JsonValue>(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<ResourceSnapshot>,
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");

View file

@ -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<typeof Models> | undefined = () => undefined;
private chatRef: () => Held<typeof Chat> | undefined = () => undefined;
private models: Held<typeof Models> | undefined;
private chatRef: Held<typeof Chat> | 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<Guess>;
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<typeof Models> | undefined {
const ref = this.models();
return liveIn(ref, "owner");
return liveIn(this.models, "owner");
}
/** The viewed conversation's participant reference once live. */
participant(): HeldIn<typeof Chat, "participant"> | 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<unknown>, 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<string, TuiCommand> = {
? 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);
},
},

View file

@ -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<MobileSettings>(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,
),
};
});
});

View file

@ -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<typeof ForwardSchema>;
/** 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<string>();
const [publishing, setPublishing] = useState<string>();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string>();
const run = async (action: () => Promise<unknown>) => {
setBusy(true);
const [forwards, change] = useOptimisticCall(useReadable(service?.forwards), applyPort, (reason) =>
setError(errorText(reason)),
);
const run = (guess: PortGuess, call: () => Promise<unknown>) => {
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 }) {
<div className="list">
{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 (
<div className="forward" key={forward.id}>
<div className="guest-row forward-row">
@ -63,10 +88,13 @@ export function PortsPanel({ onClose }: { onClose: () => void }) {
<button
type="button"
className="text-action danger"
disabled={busy}
onClick={() =>
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
</button>
@ -76,7 +104,12 @@ export function PortsPanel({ onClose }: { onClose: () => void }) {
</>
) : (
<>
<button type="button" className="text-action" onClick={() => setRemoving(forward.id)}>
<button
type="button"
className="text-action"
disabled={pending}
onClick={() => setRemoving(forward.id)}
>
Remove
</button>
{forward.url && <CopyButton text={forward.url} />}
@ -94,12 +127,7 @@ export function PortsPanel({ onClose }: { onClose: () => void }) {
<span className="small">
Anyone with the link can open localhost:{forward.port} without signing in.
</span>
<button
type="button"
className="text-action danger"
disabled={busy}
onClick={() => setPublic(true)}
>
<button type="button" className="text-action danger" onClick={() => setPublic(true)}>
Make public
</button>
<button type="button" className="text-action" onClick={() => setPublishing(undefined)}>
@ -111,7 +139,7 @@ export function PortsPanel({ onClose }: { onClose: () => void }) {
<input
type="checkbox"
checked={forward.public ?? false}
disabled={busy}
disabled={pending}
onChange={(event) =>
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("");
}}
>
<input
@ -161,7 +188,7 @@ export function PortsPanel({ onClose }: { onClose: () => void }) {
placeholder="Name (optional)"
aria-label="Name"
/>
<button type="submit" className="button primary" disabled={!service || busy || !/^\d+$/.test(port.trim())}>
<button type="submit" className="button primary" disabled={!service || !/^\d+$/.test(port.trim())}>
Add
</button>
</form>

View file

@ -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() {

View file

@ -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 <port> [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<unknown>) => {
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<typeof Ports, "owner">) => Promise<unknown>) =>
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 <port> [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 <port> [name] [public] adds one"),
...(status ? [ctx.theme.current.fg("muted", ` ${lineText(status)}`)] : []),
],
footer: () => [ctx.theme.current.fg("dim", " /ports open <port> [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 <port> [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<string, TuiCommand> = {

View file

@ -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,
);

View file

@ -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<string, boolean> = {};
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;

View file

@ -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<typeof PromptResources>) | undefined;
private ref: Held<typeof PromptResources> | undefined;
templates: readonly TemplateInfo[] = [];
skills: readonly SkillInfo[] = [];
private stop: () => void;
@ -66,9 +66,8 @@ export class PromptSurface implements TuiSurface {
];
}
async expand(text: string): Promise<string | undefined> {
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 [];

View file

@ -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<PromptLoads>;
};
@ -140,11 +141,20 @@ function promptLoads(dataDir: string, at: Location, environment: Promise<Executi
};
}
/** The session's prompt resources, which move with the session (`move`, from the `Prompt` object's routine). */
type MovingPromptLoads = PromptLoads & {
/**
* Loads them anew at `at` (from the new cwd, on the new machine) when `version` is a newer move; resolves once the
* old location's watches closed.
*/
move(at: Location, version: number): Promise<void>;
};
/**
* 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<PromptFiles, PromptFiles | undefined>("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<string, PromptLoads>();
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: {

View file

@ -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<typeof RebuildSchema>;
/** 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.

View file

@ -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: {} } },
);
});

View file

@ -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<string, TuiCommand> = {
"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);
}),
);

View file

@ -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<number> {
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<number, number>(
(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);

View file

@ -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<unknown>;
onSave: (label: string | null) => void;
onClose: () => void;
}) {
const [draft, setDraft] = useState(label ?? title);
const [error, setError] = useState<string>();
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 (
<Sheet title="Branch name" onClose={onClose}>
<form
@ -48,7 +50,6 @@ export function BranchSheet({
{resettable && (
<p className="muted small">The first branch shows the session title until it gets a label of its own.</p>
)}
{error && <p className="error small">{error}</p>}
<div className="inline-form">
<button type="submit" className="button" disabled={unchanged || (!next && !resettable)}>
Save

View file

@ -1,65 +0,0 @@
// Native catalog operations beneath the copied mobile Session sheet.
import { BACKGROUND_CONTEXT, liveIn, whenLive } from "@p2/app/v1";
import {
closeLayers,
conversationRoute,
MoreIcon,
openLayer,
useComposerState,
useHost,
useNavigation,
useObject,
useReadable,
useRoute,
useSheet,
} from "@p2/app/v1/web";
import { directoryPickerLayer } from "../envs/contract.web.tsx";
import { Sessions } from "./contract.ts";
import { SessionSheet } from "./SessionSheet.tsx";
export function SessionOptions() {
const host = useHost(),
nav = useNavigation(),
route = useRoute();
const service = useObject(Sessions, {}, { variant: "owner" }),
rows = useReadable(service?.sessions);
const session = rows?.find((row) => row.id === route.session),
sessionSheet = useSheet("session");
const { setError } = useComposerState();
const perform = (operation: Promise<unknown> | undefined) =>
void operation?.catch((error) => setError(error instanceof Error ? error.message : String(error)));
if (!service || !session || route.conversation !== session.main) return null;
return (
<>
<button
type="button"
className="icon-button"
aria-label="Session options"
onClick={() => openLayer({ kind: "sheet", name: "session" })}
>
<MoreIcon />
</button>
{sessionSheet && (
<SessionSheet
session={session}
onClose={closeLayers}
onRename={(title) => perform(service?.rename({ id: session.id, title }, BACKGROUND_CONTEXT))}
onPin={() => perform(service?.pin({ id: session.id, pinned: !session.pinned }, BACKGROUND_CONTEXT))}
onShare={() => openLayer({ kind: "sheet", name: "share" })}
onCwd={() => openLayer(directoryPickerLayer(session.env ?? null, session.cwd))}
onArchive={() => {
closeLayers();
void nav.replace(conversationRoute(route, undefined));
// Leaving the session unmounts this view and releases `service`, which cancels its calls: the
// archive holds a reference of its own until it is done.
const archiving = host.hold(Sessions, {}, BACKGROUND_CONTEXT);
perform(
whenLive(archiving)
.then(() => liveIn(archiving, "owner")?.archive({ id: session.id }, BACKGROUND_CONTEXT))
.finally(() => archiving.release()),
);
}}
/>
)}
</>
);
}

View file

@ -1,6 +1,10 @@
import { ChevronIcon, locationLabel, Sheet } from "@p2/app/v1/web";
import { useState } from "react";
import type { Session } from "./contract.ts";
/** The session's search index rebuild (`Search.rebuilds`): running, failed with why, or none. */
export type Reindex = { readonly phase: "running" } | { readonly phase: "failed"; readonly error: string } | undefined;
export function SessionSheet({
session,
onClose,
@ -9,6 +13,8 @@ export function SessionSheet({
onArchive,
onShare,
onCwd,
reindex,
onReindex,
}: {
session: Session;
onClose: () => void;
@ -17,6 +23,9 @@ export function SessionSheet({
onArchive: () => void;
onShare: () => void;
onCwd: () => void;
reindex: Reindex;
/** Re-indexes the session's history for search (long work: the row shows it running); absent without search. */
onReindex: (() => void) | undefined;
}) {
const [title, setTitle] = useState(session.title);
const changed = title.trim() !== "" && title.trim() !== session.title;
@ -55,6 +64,12 @@ export function SessionSheet({
<button type="button" className="row-button" onClick={onPin}>
<span>{session.pinned ? "Unpin" : "Pin to top"}</span>
</button>
{onReindex && (
<button type="button" className="row-button" disabled={reindex?.phase === "running"} onClick={onReindex}>
<span>{reindex?.phase === "running" ? "Rebuilding search index…" : "Rebuild search index"}</span>
{reindex?.phase === "failed" && <small className="row-value error">{reindex.error}</small>}
</button>
)}
<button type="button" className="row-button danger" onClick={onArchive}>
<span>Archive</span>
</button>

View file

@ -3,10 +3,12 @@ import type { Ctx } from "@p2/app/v1";
import {
evolve,
keyedRegistry,
literal,
local,
ModelRefSchema,
method,
param,
propertiesOf,
remote,
serverHost,
sessionHost,
@ -73,6 +75,41 @@ export const CreateSchema = obj({
thinkingLevel: opt(ThinkingLevelSchema),
});
/** A session ID: a UUID, chosen by the server or by the client (so a page can navigate before the answer). */
export const SessionIdSchema = str({ pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" });
/** `Sessions.create` from version 6: `id`, the new session's ID chosen by the caller (default a new one). */
export const CreateArgsSchema = obj({ ...propertiesOf(CreateSchema), id: opt(SessionIdSchema) });
/** A lifecycle operation: what runs as a job after its method answered. */
export const OperationKindSchema = union([literal("relocate"), literal("clone"), literal("fork"), literal("archive")]);
/**
* A lifecycle operation of a session in progress or failed (`Sessions.operations`): `session` the session it acts on,
* `target` the new session a clone or fork makes (its row exists from the start; its worker starts once the copy is
* done). `copying`: a clone's or fork's files are being copied (the target's worker waits). A failed one stays until
* it is dismissed, with why it failed; what it changed was rolled back (a failed copy's row is archived).
*/
export const OperationSchema = union([
obj({
phase: literal("running"),
id: str(),
kind: OperationKindSchema,
session: str(),
target: union([str(), nul()]),
copying: bool(),
}),
obj({
phase: literal("failed"),
id: str(),
kind: OperationKindSchema,
session: str(),
target: union([str(), nul()]),
error: str(),
}),
]);
export type Operation = Static<typeof OperationSchema>;
/**
* Archived sessions whose worker may start anyway, without resuming Durable work: rebuilding their search index
* (server, host-internal; the search plugin's `rebuildIndex`, as Pi Mobile's).
@ -88,8 +125,8 @@ export const ArchivedStarts = local("p2.archived-starts", {
});
const CatalogCreate = obj({
/** A chosen ID (a clone's or fork's, whose files exist before its entry); default a new one. */
id: opt(str({ pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" })),
/** A chosen ID (the caller's, a clone's or a fork's); default a new one. */
id: opt(SessionIdSchema),
env: opt(union([str(), nul()])),
cwd: str(),
title: str(),
@ -227,15 +264,8 @@ const SessionsV4 = evolve(SessionsV3, (e) =>
.addMethod("worker", "treeSelf", obj({ main: int({ minimum: 1 }), branches: arr(BranchSchemaV4) }), nul()),
);
/**
* Sessions as a principal sees them. The owner lists and manages all; a guest sees its own session; a worker sees
* and renames only its own session (its principal names it). 5: a branch's label may be null (the session title).
* The provider still serves 3 and 4 (its `accepts`): pages and TUIs loaded before a deploy keep working.
*
* The worker's `treeSelf`: the session's main conversation or its branches changed (a `/tree` switch or fork, a branch
* renamed), or the worker started; the catalog entry follows, so clients open the main one and list the branches.
*/
export const Sessions = evolve(SessionsV4, (e) =>
/** 5: a branch's label may be null (the session title). */
const SessionsV5 = evolve(SessionsV4, (e) =>
e
.state("owner", "sessions", arr(SessionSchema))
.state("guest", "sessions", arr(SessionSchema))
@ -246,6 +276,62 @@ export const Sessions = evolve(SessionsV4, (e) =>
.args("treeSelf", obj({ main: int({ minimum: 1 }), branches: arr(BranchSchema) })),
);
/**
* Sessions as a principal sees them. The owner lists and manages all; a guest sees its own session; a worker sees
* and renames only its own session (its principal names it). The provider still serves 3, 4 and 5 (its `accepts`):
* pages and TUIs loaded before a deploy keep working.
*
* 6, client-chosen IDs and lifecycle operations as jobs: `create` takes the new session's `id`, `clone` and `fork` the
* copy's (`copy`), so a page navigates at once. `clone`, `fork`, `relocate` and `archive` answer once their effect is
* visible (the copy's row, the archived row) or started (a move), and their long part runs as a job, shown in
* `operations` (running, then gone or failed with its error) until `dismissOperation`. A `relocate` the worker
* refuses (busy) is a failed operation.
*
* The worker's `treeSelf`: the session's main conversation or its branches changed (a `/tree` switch or fork, a branch
* renamed), or the worker started; the catalog entry follows, so clients open the main one and list the branches.
*/
export const Sessions = evolve(SessionsV5, (e) =>
e
.args("create", CreateArgsSchema)
.args("clone", obj({ id: str(), copy: opt(SessionIdSchema) }))
.args(
"fork",
obj({
id: str(),
conversation: int({ minimum: 1 }),
before: int({ minimum: 1 }),
copy: opt(SessionIdSchema),
}),
)
.addState("owner", "operations", arr(OperationSchema))
.addMethod("owner", "dismissOperation", obj({ operation: str() }), nul()),
);
const Location = obj({ id: str(), cwd: str({ minLength: 1 }), env: union([str(), nul()]) });
/**
* The sessions' lifecycle operations (server, host-internal; a host-lifetime object, so its job runs persist): each
* method answers once its visible effect committed and runs the rest as a job (`operations`). `move` is the agent's
* relocation (`SessionMoves`): it waits for the job and answers why a busy worker refused (nothing recorded).
*/
export const SessionOperations = local("p2.session-operations", {
version: 1,
params: {},
members: {
operations: arr(OperationSchema),
move: method(Location, obj({ refused: union([str(), nul()]) })),
relocate: method(Location),
clone: method(obj({ id: str(), copy: SessionIdSchema }), SessionSchema),
fork: method(
obj({ id: str(), conversation: int({ minimum: 1 }), before: int({ minimum: 1 }), copy: SessionIdSchema }),
SessionSchema,
),
archive: method(obj({ id: str() })),
restore: method(obj({ id: str() })),
dismiss: method(obj({ operation: str() })),
},
});
/**
* Moving a session to another location (server, host-internal): `Sessions.relocate` for the owner, and the locations
* plugin's `location_move` once the main agent's turn has ended. Validates the destination (throws when it is not a

View file

@ -52,12 +52,19 @@ export async function copySessionStore(dataDir: string, from: string, to: string
);
}
/** Removes what a failed copy left for the session `id` (it has no catalog entry yet, or an archived one). */
/** Removes the copy's per-session directories (a fork starting its copy over). */
export async function removeSessionDirectories(dataDir: string, id: string): Promise<void> {
await Promise.all(
["artifacts", "downloads", "uploads"].map((kind) =>
rm(join(dataDir, kind, id), { recursive: true, force: true }),
),
);
}
/** Removes what a failed copy left for the session `id` (its row is archived, its worker stopped). */
export async function removeSessionCopy(dataDir: string, id: string): Promise<void> {
await Promise.all([
...["", "-wal", "-shm"].map((suffix) => rm(join(dataDir, "sessions", `${id}.sqlite${suffix}`), { force: true })),
...["artifacts", "downloads", "uploads"].map((kind) =>
rm(join(dataDir, kind, id), { recursive: true, force: true }),
),
removeSessionDirectories(dataDir, id),
]);
}

View file

@ -1,23 +1,34 @@
// The session catalog and its views (server): the sidebar's rows, creating and renaming sessions, and the lifecycle
// operations (relocate, clone, fork, archive), each a method that answers once its visible effect committed and a job
// for its long part, shown in `Sessions.operations` while it runs and when it failed.
import { randomUUID } from "node:crypto";
import { mkdir, stat } from "node:fs/promises";
import { homedir } from "node:os";
import { isAbsolute, resolve } from "node:path";
import { arr, bool, nul, obj, str, union, unknown } from "@earendil-works/pi-schema";
import {
type AcceptsTable,
type Context,
type Ctx,
contribute,
defineFacet,
defineJob,
defineState,
deriveTyped,
forbidden,
isGuest,
isOwner,
isWorker,
type JobError,
type Location,
literal,
nativeOf,
type Ref,
type SessionInit,
serveKeyed,
type Tx,
until,
WorkerBoundary,
withAbortSignal,
} from "@p2/app/v1";
import { Server, serverOf, WorkerInitValues } from "@p2/app/v1/server";
import { EnvManagers, EnvService } from "../envs/contract.ts";
@ -29,13 +40,16 @@ import {
Catalog,
isThinkingLevel,
MAX_TITLE,
type Operation,
OperationSchema,
type Session,
SessionArchived,
SessionMoves,
SessionOperations,
Sessions,
SessionTransfer,
} from "./contract.ts";
import { copySessionDirectories, copySessionStore, removeSessionCopy } from "./copy.ts";
import { copySessionDirectories, copySessionStore, removeSessionCopy, removeSessionDirectories } from "./copy.ts";
/** A row as `Sessions` v4 and `Catalog` v3 consumers read it: a null branch label is the session title. */
function sessionV4(row: Session) {
@ -71,9 +85,12 @@ const CATALOG_ACCEPTS: AcceptsTable<typeof Catalog> = {
},
};
/** Older arguments of `create`, `clone` and `fork` are a subset of the current ones (no chosen IDs). */
const SAME_ARGS = { args: <T>(old: T) => old };
/**
* Older `Sessions` consumers (a page or TUI loaded before a deploy, a worker not restarted yet) read rows mapped
* back; v4's `treeSelf` labels are text, which the current version takes as it is.
* back; v4's `treeSelf` labels are text, which the current version takes as it is. None of them sees `operations`.
*/
const SESSIONS_ACCEPTS: AcceptsTable<typeof Sessions> = {
3: {
@ -82,7 +99,11 @@ const SESSIONS_ACCEPTS: AcceptsTable<typeof Sessions> = {
guest: { sessions: (rows) => rows.map(sessionV3) },
worker: { session: (row) => (row === null ? null : sessionV3(row)) },
},
methods: { create: { result: sessionV3 }, clone: { result: sessionV3 }, fork: { result: sessionV3 } },
methods: {
create: { ...SAME_ARGS, result: sessionV3 },
clone: { ...SAME_ARGS, result: sessionV3 },
fork: { ...SAME_ARGS, result: sessionV3 },
},
},
4: {
state: {
@ -91,36 +112,39 @@ const SESSIONS_ACCEPTS: AcceptsTable<typeof Sessions> = {
worker: { session: (row) => (row === null ? null : sessionV4(row)) },
},
methods: {
create: { result: sessionV4 },
clone: { result: sessionV4 },
fork: { result: sessionV4 },
treeSelf: { args: (old) => old },
create: { ...SAME_ARGS, result: sessionV4 },
clone: { ...SAME_ARGS, result: sessionV4 },
fork: { ...SAME_ARGS, result: sessionV4 },
treeSelf: SAME_ARGS,
},
},
5: { methods: { create: SAME_ARGS, clone: SAME_ARGS, fork: SAME_ARGS } },
};
/** Contributes each worker's session parameters; refuses archived and unknown sessions. */
const cleanTitle = (title: string): string => title.replace(/\s+/g, " ").trim();
/** `~` and `~/…` on this machine. */
const expandHome = (cwd: string): string =>
cwd === "~" ? homedir() : cwd.startsWith("~/") ? resolve(homedir(), cwd.slice(2)) : cwd;
/**
* Fences the session's running worker for `doing` ("changing directory"): undefined once fenced, else why it refused
* (it has work, or `to` is a move its `RelocationChecks` refuse; nothing fenced). Runs in `root`, a root of the
* server's own (see `lifecycle`).
* (it has work, or `to` is a move its `RelocationChecks` refuse; nothing fenced). `ctx` acts as the server (a local
* object's job or method): the worker's `WorkerBoundary` answers only the server.
*/
async function fenceWorker(
catalog: Ref<typeof Catalog>,
children: Ref<typeof ChildStore>,
id: string,
doing: string,
root: Ctx,
ctx: Ctx,
to?: Location,
): Promise<string | undefined> {
const boundary = await root.get(WorkerBoundary, { session: id });
const result = await boundary.prepare(to ? { to } : null, root);
const boundary = await ctx.get(WorkerBoundary, { session: id });
const result = await boundary.prepare(to ? { to } : null, ctx);
if (result.accepted) return undefined;
if (result.refusal !== undefined) return result.refusal;
const row = await catalog.get({ id }, root);
const row = await catalog.get({ id }, ctx);
// A `/tree` branch left while running goes on in the background (docs/tree.md); conversation 1 is top-level before
// the worker first reports `branches`.
const branchOf = (conversation: number) => row.branches.find((branch) => branch.conversation === conversation);
@ -144,14 +168,81 @@ async function fenceWorker(
: `Background work is still running (for example a message waiting for a subagent's answer): wait for it or stop the subagent before ${doing}`;
}
/** Lifecycle jobs persist and run again after a crash: each run is idempotent (a copy starts over, a move repeats). */
const RERUN = { resume: "rerun" } as const;
const RelocateJob = defineJob("p2.sessions.relocate", {
version: 1,
args: obj({
id: str(),
cwd: str(),
env: union([str(), nul()]),
/** The owner's move (a refusal is a failed operation) or the agent's (`SessionMoves`: its tool reports it). */
by: union([literal("owner"), literal("agent")]),
}),
result: obj({ refused: union([str(), nul()]) }),
persist: RERUN,
});
const CloneJob = defineJob("p2.sessions.clone", {
version: 1,
args: obj({ id: str(), copy: str() }),
/** Why a busy worker refused the copy (rolled back: a failed operation), else null. */
result: obj({ refused: union([str(), nul()]) }),
persist: RERUN,
});
/** A fork's progress: copying its files (its worker waits), then seeding its history (through its worker). */
const ForkProgress = defineState("p2.sessions.fork.progress", {
schema: obj({ copying: bool() }),
version: 1,
initial: () => ({ copying: true }),
persist: true,
checkpointWhen: () => true,
});
const ForkJob = defineJob("p2.sessions.fork", {
version: 1,
/** `entries`: the history the fork holds, read from the source when the fork was asked for. */
args: obj({ id: str(), copy: str(), entries: arr(unknown()) }),
result: nul(),
state: ForkProgress,
persist: RERUN,
});
const ArchiveJob = defineJob("p2.sessions.archive", {
version: 1,
args: obj({ id: str() }),
result: nul(),
persist: RERUN,
});
/** Failed operations, until dismissed (the latest `MAX_FAILED`). */
const Failed = defineState("p2.sessions.failed", {
schema: obj({ list: arr(OperationSchema) }),
version: 1,
initial: () => ({ list: [] }),
});
const MAX_FAILED = 20;
/** Why a job failed, in words. */
function failureText(error: JobError): string {
if (error.kind === "threw") return error.error.message;
if (error.kind === "aborted") return "The operation was interrupted";
return `The operation was stopped after ${error.restarts} restarts`;
}
/** How long a worker start waits for its session's row (a client-chosen ID held before `create` answered). */
const ROW_WAIT_MS = 10_000;
export default defineFacet((host) => {
serveKeyed(host, SessionArchived);
host.serve(
Catalog,
(_params, self) => {
const server = self.get(Server, {});
const archived = self.registry(SessionArchived);
// The catalog's sessions, read from the database when it opens and after every change.
// The catalog's sessions: its database is this object's native, and every write sets the rows in the same
// method, before it answers.
const sessions = self.writable<Session[]>([]);
const db = self.open(
async () => {
@ -174,10 +265,9 @@ export default defineFacet((host) => {
sessions.set(db.value.all());
return null;
},
update: async ({ id, ...change }, ctx) => {
update: async ({ id, ...change }) => {
const session = db.value.update(id, change);
sessions.set(db.value.all());
if (change.archived === true) for (const close of archived.value.values()) await close(id, ctx);
return session;
},
};
@ -200,83 +290,352 @@ export default defineFacet((host) => {
};
});
contribute(host, "p2.sessions-init", (self) => {
host.serve(SessionOperations, (_params, self) => {
const catalog = self.get(Catalog, {});
const envs = self.get(EnvService, {});
const archivedStarts = self.get(ArchivedStarts, {});
self.contribute(WorkerInitValues, {
session: async (id, ctx): Promise<SessionInit> => {
const session = await catalog.get({ id }, ctx);
// An archived session starts only to rebuild its index, and then without resuming its work.
const indexing = session.archivedAt !== null && archivedStarts.allowed.value.includes(id);
if (session.archivedAt !== null && !indexing) throw forbidden("Session is archived");
if (session.env !== null && session.env !== undefined && !Object.hasOwn(envs.configs.value, session.env))
throw new Error(`Environment "${session.env}" is not configured; refusing local execution`);
return {
id: session.id,
cwd: session.cwd,
env: session.env ?? null,
model: session.model,
thinkingLevel: session.thinkingLevel as SessionInit["thinkingLevel"],
...(indexing ? { resume: false } : {}),
};
},
});
});
host.serve(SessionMoves, (_params, self) => {
const catalog = self.get(Catalog, {});
const server = serverOf(self);
self.get(EnvService, {});
const server = self.get(Server, {});
const natives = serverOf(self);
const envService = self.get(EnvService, {});
const envs = nativeOf(self, EnvManagers, "The SSH manager");
const children = self.get(ChildStore, {});
return {
relocate: async ({ id, cwd, env }, ctx) => {
const session = await catalog.get({ id }, ctx);
if (session.archivedAt !== null) throw new Error("Session is archived");
// The main conversation and the conversations following it move; subagents with their own location stay
// where they are (on another machine too). A destination that is not configured refuses here.
if (env) cwd = await envs().manager.workingDirectory(env, cwd, false);
else {
cwd = resolve(cwd === "~" ? homedir() : cwd.startsWith("~/") ? resolve(homedir(), cwd.slice(2)) : cwd);
if (!(await stat(cwd)).isDirectory()) throw new Error("Working directory is not a directory");
}
const location = { cwd, env: env ?? null };
const archivedStarts = self.get(ArchivedStarts, {});
const archived = self.registry(SessionArchived);
const failed = self.state(Failed);
/** Copies whose row is being made: their worker waits like one whose files are being copied. */
const preparing = self.writable<readonly string[]>([]);
/** A finished run that failed (a refused owner's move too) stays in `operations` until dismissed. */
const recordFailure = async (tx: Tx, operation: Omit<Extract<Operation, { phase: "failed" }>, "phase">) => {
const list = (await tx.draft(failed)).list;
list.push({ phase: "failed", ...operation });
if (list.length > MAX_FAILED) list.splice(0, list.length - MAX_FAILED);
};
/** A failed copy: its row archived first (so its worker cannot start), its worker stopped, its files gone. */
const failCopy = async (copy: string, ctx: Ctx) => {
if (catalog.sessions.value.some((session) => session.id === copy))
await catalog.update({ id: copy, archived: true }, ctx);
await natives().workers.suspend(copy);
await removeSessionCopy(server.info.value.dataDir, copy);
};
/**
* Moves session `id` to `location`: a stopped worker only gets the catalog update; a running one is fenced, the
* catalog moves and the worker follows in place (restarted from the catalog when it cannot). Returns why a busy
* worker refused (nothing moved), else null.
*/
const relocate = async (id: string, location: Location, ctx: Ctx): Promise<string | null> => {
let refused: string | null = null;
const boundary = () => ctx.get(WorkerBoundary, { session: id });
try {
await natives().workers.relocate(id, {
prepare: async () => {
const refusal = await fenceWorker(catalog, children, id, "changing directory", ctx, location);
refused = refusal ?? null;
return refusal;
},
update: async () => {
await catalog.update({ id, ...location }, ctx);
},
move: async () => {
await (await boundary()).relocate(location, ctx);
},
cancel: async () => {
await (await boundary()).cancel(ctx);
},
});
} catch (error) {
// A busy worker (or one starting or stopping): nothing moved.
if (refused !== null) return refused;
if (error instanceof Error && error.message.startsWith("Background work is still running"))
return error.message;
throw error;
}
return null;
};
const relocations = self.job(RelocateJob, {
run: async ({ id, cwd, env }, ctx) => ({ refused: await relocate(id, { cwd, env }, ctx) }),
finish: async (outcome, tx) => {
const error =
outcome.kind === "failed"
? failureText(outcome.error)
: outcome.args.by === "owner"
? outcome.result.refused
: null;
if (error !== null)
await recordFailure(tx, {
id: outcome.id,
kind: "relocate",
session: outcome.args.id,
target: null,
error,
});
},
});
const clones = self.job(CloneJob, {
run: async ({ id, copy }, ctx) => {
const dataDir = server.info.value.dataDir;
let refused: string | null = null;
try {
// In a root of the server's own: a caller giving up must not cut a fenced worker off between its steps.
await server().root((root) => {
// Only a running worker is asked (a hold would start a stopped one); it follows the move in place.
const boundary = () => root.get(WorkerBoundary, { session: id });
return server().workers.relocate(id, {
prepare: async () => {
const refusal = await fenceWorker(catalog, children, id, "changing directory", root, location);
refused = refusal ?? null;
return refusal;
},
update: async () => {
await catalog.update({ id, ...location }, root);
},
move: async () => {
await (await boundary()).relocate(location, root);
},
cancel: async () => {
await (await boundary()).cancel(root);
},
});
});
// The store is copied while the worker is stopped: SQLite's file and WAL belong together then.
await natives().workers.pause(
id,
async () => {
const refusal = await fenceWorker(catalog, children, id, "cloning", ctx);
refused = refusal ?? null;
return refusal;
},
async () => {
await removeSessionCopy(dataDir, copy);
await copySessionStore(dataDir, id, copy);
await copySessionDirectories(dataDir, id, copy);
},
"cloning",
);
// A move while the clone waited for the pause belongs to the copy too.
const [source, row] = await Promise.all([catalog.get({ id }, ctx), catalog.get({ id: copy }, ctx)]);
if (source.cwd !== row.cwd || (source.env ?? null) !== (row.env ?? null))
await catalog.update({ id: copy, cwd: source.cwd, env: source.env ?? null }, ctx);
return { refused: null };
} catch (error) {
// A busy worker (or one starting or stopping): nothing moved.
await failCopy(copy, ctx);
// A busy worker (or one starting or stopping) is an answer, not a failure of the job.
if (refused !== null) return { refused };
if (error instanceof Error && error.message.startsWith("Background work is still running"))
return { refused: error.message };
throw error;
}
return { refused: null };
},
finish: async (outcome, tx) => {
const error = outcome.kind === "failed" ? failureText(outcome.error) : outcome.result.refused;
if (error !== null)
await recordFailure(tx, {
id: outcome.id,
kind: "clone",
session: outcome.args.id,
target: outcome.args.copy,
error,
});
},
});
const forks = self.job(ForkJob, {
run: async ({ id, copy, entries }, ctx) => {
const dataDir = server.info.value.dataDir;
try {
if (ctx.state.value.copying) {
await removeSessionDirectories(dataDir, copy);
await copySessionDirectories(dataDir, id, copy);
await ctx.state.change((progress) => {
progress.copying = false;
}, ctx);
}
// The copy's worker starts now (this hold, or a page that navigated to it) and writes the history.
await (await ctx.get(SessionTransfer, { session: copy })).seed({ entries }, ctx);
} catch (error) {
await failCopy(copy, ctx);
throw error;
}
return null;
},
finish: async (outcome, tx) => {
if (outcome.kind === "failed")
await recordFailure(tx, {
id: outcome.id,
kind: "fork",
session: outcome.args.id,
target: outcome.args.copy,
error: failureText(outcome.error),
});
},
});
const archives = self.job(ArchiveJob, {
run: async ({ id }, ctx) => {
// Archive stops the worker whatever it is doing; its storage and history stay. Its holders (a page's
// lingering conversation) wait for a restore rather than start a worker the archive refuses.
for (const close of archived.value.values()) await close(id, ctx);
await natives().workers.suspend(id);
return null;
},
finish: async (outcome, tx) => {
if (outcome.kind === "failed")
await recordFailure(tx, {
id: outcome.id,
kind: "archive",
session: outcome.args.id,
target: null,
error: failureText(outcome.error),
});
},
});
const operations = deriveTyped(
self,
[relocations.running, clones.running, forks.running, archives.running, failed],
([moving, cloning, forking, archiving, { list }]): Operation[] => [
...moving.flatMap((run) =>
run.phase === "blocked" || run.args.by === "agent"
? []
: [running(run.id, "relocate", run.args.id, null, false)],
),
...cloning.flatMap((run) =>
run.phase === "blocked" ? [] : [running(run.id, "clone", run.args.id, run.args.copy, true)],
),
...forking.flatMap((run) =>
run.phase === "blocked" ? [] : [running(run.id, "fork", run.args.id, run.args.copy, run.state.copying)],
),
...archiving.flatMap((run) =>
run.phase === "blocked" ? [] : [running(run.id, "archive", run.args.id, null, false)],
),
...list,
],
);
/** Sessions whose worker must not start yet: a copy's row being made, or its files being copied. */
const copying = deriveTyped(self, [preparing, operations], ([made, list]) => [
...made,
...list.flatMap((operation) =>
operation.phase === "running" && operation.copying && operation.target !== null ? [operation.target] : [],
),
]);
/** A destination as the move stores it: `~` expanded and checked to be a directory (on its environment). */
const destination = async ({ cwd, env }: Location): Promise<Location> => {
if (env) return { cwd: await envs().manager.workingDirectory(env, cwd, false), env };
const local = resolve(expandHome(cwd));
if (!(await stat(local)).isDirectory()) throw new Error("Working directory is not a directory");
return { cwd: local, env: null };
};
const liveSession = async (id: string, ctx: Ctx): Promise<Session> => {
const session = await catalog.get({ id }, ctx);
if (session.archivedAt !== null) throw new Error("Session is archived");
return session;
};
/** A copy's catalog entry: the source's place, model and level under a marked title. */
const copyOf = (source: Session, id: string, mark: string, ctx: Ctx) =>
catalog.create(
{
id,
cwd: source.cwd,
env: source.env ?? null,
title: cleanTitle(`${source.title} (${mark})`).slice(0, MAX_TITLE),
titleEdited: true,
model: source.model,
thinkingLevel: source.thinkingLevel,
},
ctx,
);
/**
* Makes a copy's row and starts the job filling it, its worker held back meanwhile. A copy that exists already is
* a retry of the same request: its row is the answer.
*/
const startCopy = async (
source: Session,
copy: string,
mark: string,
start: () => Promise<unknown>,
ctx: Ctx,
) => {
const existing = catalog.sessions.value.find((session) => session.id === copy);
if (existing) return existing;
preparing.set([...preparing.value, copy]);
try {
const row = await copyOf(source, copy, mark, ctx);
await start();
return row;
} catch (error) {
await failCopy(copy, ctx);
throw error;
} finally {
preparing.set(preparing.value.filter((id) => id !== copy));
}
};
// The start data of a session's worker; refuses archived and unknown sessions. A copy's worker waits until its
// files are there; a client-chosen ID held before its row exists waits for the row.
self.contribute(WorkerInitValues, {
session: async (id, context): Promise<SessionInit> => {
const waiting = withAbortSignal(AbortSignal.timeout(ROW_WAIT_MS), context);
await until(catalog.sessions, (rows) => rows.some((row) => row.id === id), waiting).catch(() => {});
await until(copying, (ids) => !ids.includes(id), context);
return startData(id, context);
},
});
const startData = async (id: string, context: Context): Promise<SessionInit> => {
const session = await catalog.get({ id }, context);
// An archived session starts only to rebuild its index, and then without resuming its work.
const indexing = session.archivedAt !== null && archivedStarts.allowed.value.includes(id);
if (session.archivedAt !== null && !indexing) throw forbidden("Session is archived");
if (session.env !== null && session.env !== undefined && !Object.hasOwn(envService.configs.value, session.env))
throw new Error(`Environment "${session.env}" is not configured; refusing local execution`);
return {
id: session.id,
cwd: session.cwd,
env: session.env ?? null,
model: session.model,
thinkingLevel: session.thinkingLevel as SessionInit["thinkingLevel"],
...(indexing ? { resume: false } : {}),
};
};
return {
operations,
move: async ({ id, ...target }, ctx) => {
await liveSession(id, ctx);
const location = await destination(target);
const run = await relocations.start({ id, ...location, by: "agent" }, ctx);
const outcome = await run.outcome(ctx);
if (outcome.kind === "failed") throw new Error(failureText(outcome.error));
return outcome.result;
},
relocate: async ({ id, ...target }, ctx) => {
await liveSession(id, ctx);
const location = await destination(target);
await relocations.start({ id, ...location, by: "owner" }, ctx);
return null;
},
clone: async ({ id, copy }, ctx) => {
const source = await liveSession(id, ctx);
return startCopy(source, copy, "copy", () => clones.start({ id, copy }, ctx, { key: copy }), ctx);
},
fork: async ({ id, copy, conversation, before }, ctx) => {
const source = await liveSession(id, ctx);
// Read now, so a fork of a subagent or of an unknown message is refused before anything is made. This local
// object's `ctx.get` acts as the server, whom `SessionTransfer` serves.
const { entries } = await (await ctx.get(SessionTransfer, { session: id })).history(
{ conversation, before },
ctx,
);
return startCopy(source, copy, "fork", () => forks.start({ id, copy, entries }, ctx, { key: copy }), ctx);
},
archive: async ({ id }, ctx) => {
await catalog.update({ id, archived: true }, ctx);
await archives.start({ id }, ctx, { key: id });
return null;
},
restore: async ({ id }, ctx) => {
// After a running archive of it: its worker stop must not land after the restore.
const archiving = archives.running.value.find((run) => run.phase !== "blocked" && run.key === id);
if (archiving) await archives.outcome(archiving.id, ctx).catch(() => {});
await catalog.update({ id, archived: false }, ctx);
// A start refused while archived left the worker failed or suspended: its holders start it again.
await natives().workers.resume(id);
return null;
},
dismiss: async ({ operation }, ctx) => {
await failed.change((draft) => {
draft.list = draft.list.filter((each) => each.id !== operation);
}, ctx);
return null;
},
};
});
host.serve(SessionMoves, (_params, self) => {
const operations = self.get(SessionOperations, {});
return { relocate: (target, ctx) => operations.move(target, ctx) };
});
host.serve(
Sessions,
(_params, principal, self) => {
@ -322,35 +681,11 @@ export default defineFacet((host) => {
return { variant: "guest", members: { sessions: sessionsView } };
}
if (!isOwner(principal)) throw forbidden("sessions are owner-only");
const lifecycleOf = serverOf(self);
const models = self.get(ModelConfig, {});
const resources = nativeOf(self, ModelResources, "The model configuration");
self.get(EnvService, {});
const envs = nativeOf(self, EnvManagers, "The SSH manager");
const children = self.get(ChildStore, {});
const server = self.get(Server, {});
const moves = self.get(SessionMoves, {});
const fence = (id: string, doing: string, root: Ctx, to?: Location) =>
fenceWorker(catalog, children, id, doing, root, to);
/**
* Runs a lifecycle operation's steps in a root of the server's own, not the caller's: a caller giving up (an
* aborted call, a closed tab) must not cut a fenced worker off between its steps.
*/
const lifecycle = <T>(fn: (root: Ctx) => Promise<T>): Promise<T> => lifecycleOf().root(fn);
/** A copy's catalog entry: the source's place, model and level under a marked title. */
const copyOf = (source: Session, id: string, mark: string, ctx: Ctx) =>
catalog.create(
{
id,
cwd: source.cwd,
env: source.env ?? null,
title: cleanTitle(`${source.title} (${mark})`).slice(0, MAX_TITLE),
titleEdited: true,
model: source.model,
thinkingLevel: source.thinkingLevel,
},
ctx,
);
const lifecycle = self.get(SessionOperations, {});
const defaultCwd = deriveTyped(
self,
[catalog.sessions],
@ -368,10 +703,14 @@ export default defineFacet((host) => {
sessions: catalog.sessions,
defaultCwd,
defaultEnv,
create: async ({ env, cwd, createDirectory, title, model, thinkingLevel }, ctx) => {
operations: lifecycle.operations,
create: async ({ id, env, cwd, createDirectory, title, model, thinkingLevel }, ctx) => {
// A chosen ID already in the catalog is a retry of the same request (its answer was lost).
const existing = id === undefined ? undefined : catalog.sessions.value.find((row) => row.id === id);
if (existing) return existing;
if (env) cwd = await envs().manager.workingDirectory(env, cwd, createDirectory);
else {
cwd = cwd === "~" ? homedir() : cwd.startsWith("~/") ? resolve(homedir(), cwd.slice(2)) : cwd;
cwd = expandHome(cwd);
if (!isAbsolute(cwd)) throw new Error("The working directory must be an absolute path");
if (createDirectory) await mkdir(cwd, { recursive: true });
if (!(await stat(cwd)).isDirectory()) throw new Error("Working directory is not a directory");
@ -385,6 +724,7 @@ export default defineFacet((host) => {
const level = thinkingLevel ?? fallback?.thinkingLevel;
return catalog.create(
{
...(id === undefined ? {} : { id }),
cwd,
env: env ?? null,
title: cleanTitle(title ?? "").slice(0, 100) || "New session",
@ -395,63 +735,9 @@ export default defineFacet((host) => {
ctx,
);
},
relocate: async (target, ctx) => {
const { refused } = await moves.relocate(target, ctx);
if (refused !== null) throw new Error(refused);
return null;
},
clone: async ({ id }, ctx) => {
if ((await catalog.get({ id }, ctx)).archivedAt !== null) throw new Error("Session is archived");
const dataDir = server.info.value.dataDir;
const copy = randomUUID();
try {
// The store is copied while the worker is stopped: SQLite's file and WAL belong together then.
await lifecycle((root) =>
lifecycleOf().workers.pause(
id,
() => fence(id, "cloning", root),
async () => {
await copySessionStore(dataDir, id, copy);
await copySessionDirectories(dataDir, id, copy);
},
"cloning",
),
);
// Read after the copy: a move or rename while waiting for the pause belongs to the copy too.
return await copyOf(await catalog.get({ id }, ctx), copy, "copy", ctx);
} catch (error) {
await removeSessionCopy(dataDir, copy);
throw error;
}
},
fork: async ({ id, conversation, before }, ctx) => {
const source = await catalog.get({ id }, ctx);
if (source.archivedAt !== null) throw new Error("Session is archived");
// SessionTransfer is the server's: reached in a root of its own (a method's `ctx.get` acts as the caller).
const { entries } = await lifecycle(async (root) =>
(await root.get(SessionTransfer, { session: id })).history({ conversation, before }, root),
);
const dataDir = server.info.value.dataDir;
const copy = randomUUID();
let created: Session | undefined;
try {
await copySessionDirectories(dataDir, id, copy);
created = await copyOf(source, copy, "fork", ctx);
// Before anyone knows the new ID, its worker writes the history into its main conversation.
await lifecycle(async (root) =>
(await root.get(SessionTransfer, { session: copy })).seed({ entries }, root),
);
return created;
} catch (error) {
if (created) {
await catalog.update({ id: copy, archived: true }, ctx).catch(() => {});
await lifecycleOf()
.workers.suspend(copy)
.catch(() => {});
} else await removeSessionCopy(dataDir, copy);
throw error;
}
},
relocate: (target, ctx) => lifecycle.relocate(target, ctx),
clone: ({ id, copy }, ctx) => lifecycle.clone({ id, copy: copy ?? randomUUID() }, ctx),
fork: ({ copy, ...from }, ctx) => lifecycle.fork({ ...from, copy: copy ?? randomUUID() }, ctx),
rename: async ({ id, title }, ctx) => {
const clean = cleanTitle(title);
if (!clean || clean.length > MAX_TITLE)
@ -463,24 +749,23 @@ export default defineFacet((host) => {
await catalog.update({ id, pinned }, ctx);
return null;
},
archive: async ({ id }) => {
await lifecycle(async (root) => {
await catalog.update({ id, archived: true }, root);
// Archive stops the worker whatever it is doing; its storage and history stay. Its holders (a page's
// lingering conversation) wait for a restore rather than start a worker the archive refuses.
await lifecycleOf().workers.suspend(id);
});
return null;
},
restore: async ({ id }, ctx) => {
await catalog.update({ id, archived: false }, ctx);
// A start refused while archived left the worker failed: the next hold starts it again.
await lifecycleOf().workers.retry(id);
return null;
},
archive: (args, ctx) => lifecycle.archive(args, ctx),
restore: (args, ctx) => lifecycle.restore(args, ctx),
dismissOperation: (args, ctx) => lifecycle.dismiss(args, ctx),
},
};
},
{ accepts: SESSIONS_ACCEPTS },
);
});
/** A running operation's entry. */
function running(
id: string,
kind: Operation["kind"],
session: string,
target: string | null,
copying: boolean,
): Operation {
return { phase: "running", id, kind, session, target, copying };
}

View file

@ -18,13 +18,14 @@ import {
truncateToWidth,
visibleWidth,
} from "@earendil-works/pi-tui";
import type { Held } from "@p2/app/v1";
import { BACKGROUND_CONTEXT, contribute, defineFacet, followRegistry, liveIn, statusOf } from "@p2/app/v1";
import type { Held, HeldIn } from "@p2/app/v1";
import { BACKGROUND_CONTEXT, contribute, defineFacet, followRegistry, liveIn } from "@p2/app/v1";
import type { TuiContext } from "@p2/app/v1/tui";
import {
confirm,
keyHint,
lineText,
type OptimisticAdd,
type SessionChild,
type SessionHit,
type TuiCommand,
@ -38,7 +39,7 @@ import {
TuiSlots,
type TuiSurface,
} from "@p2/app/v1/tui";
import { MAX_TITLE, type Session, Sessions } from "./contract.ts";
import { MAX_TITLE, type Operation, type Session, Sessions } from "./contract.ts";
import {
completeLocation,
envNames,
@ -74,6 +75,22 @@ function age(timestamp: number): string {
}
const where = (session: Session): string => `${session.env ? `${session.env}:` : ""}${session.cwd}`;
/** How operations read in the selector and on status lines, by kind. */
const OPERATION_TEXT: Record<Operation["kind"], { running: string; failed: string; done: string }> = {
relocate: { running: "moving…", failed: "Move failed", done: "Session moved" },
clone: { running: "cloning…", failed: "Clone failed", done: "Clone ready" },
fork: { running: "forking…", failed: "Fork failed", done: "Fork ready" },
archive: { running: "archiving…", failed: "Archive failed", done: "Session archived" },
};
/** A change to one session's row while its call is in flight (a rename, a pin, an archive or restore, a move). */
type RowGuess = {
readonly id: string;
readonly change: Partial<Pick<Session, "title" | "pinned" | "archivedAt" | "env" | "cwd">>;
};
const applyRow = (rows: Session[], { id, change }: RowGuess): Session[] =>
rows.map((row) => (row.id === id ? { ...row, ...change } : row));
export class SessionsSurface implements TuiSurface {
readonly scope: TuiScope;
readonly runtime: TuiRuntime;
@ -83,7 +100,12 @@ export class SessionsSurface implements TuiSurface {
focus: Component & Focusable = this.search;
/** The "+ New session" prompt (directory and environment). */
private location: LocationInput | undefined;
private rows: Session[] = [];
private received: Session[] = [];
/** The catalog's lifecycle operations (owner): running ones mark their rows, failed ones are announced. */
private operations: Operation[] = [];
/** Row changes whose calls are in flight: the list shows them at once and rolls back a refused one. */
private shownRows: () => Session[];
private editing: OptimisticAdd<RowGuess>;
private children: Readonly<Record<string, SessionChild[]>> = {};
private hits: SessionHit[] = [];
private sources: TuiSessionSource[] = [];
@ -105,21 +127,23 @@ export class SessionsSurface implements TuiSurface {
private sort: SortMode = "threaded";
private selectedIndex = 0;
private visible: Row[] = [];
private service: () => Held<typeof Sessions>;
private service: Held<typeof Sessions>;
constructor(runtime: TuiRuntime) {
this.runtime = runtime;
this.scope = new TuiScope(runtime);
[this.shownRows, this.editing] = this.scope.optimistic(() => this.received, applyRow);
this.service = this.scope.watch(Sessions, {}, (ref, follow) => {
this.owner = ref.variant === "owner";
const viewer = liveIn(ref, "owner", "guest");
if (!viewer) return;
follow(viewer.sessions, (rows) => {
this.rows = rows;
this.received = rows;
this.followMains(rows);
this.rebuild();
});
if (ref.variant === "owner") {
follow(ref.operations, (operations) => this.followOperations(operations));
follow(ref.defaultCwd, (value) => {
this.defaultCwd = value ?? "~";
this.rebuild();
@ -162,6 +186,73 @@ export class SessionsSurface implements TuiSurface {
return this.runtime.ctx;
}
/**
* Announces what changed in the lifecycle operations: a failure (as an error line until `/dismiss-failed`), and a
* running operation that ended without one (a move done, a copy ready).
*/
private followOperations(operations: Operation[]): void {
const before = new Map(this.operations.map((operation) => [operation.id, operation]));
this.operations = operations;
for (const operation of operations)
if (operation.phase === "failed" && before.get(operation.id)?.phase !== "failed")
this.runtime.notify(
`${OPERATION_TEXT[operation.kind].failed}: ${lineText(operation.error)} · /dismiss-failed`,
"error",
);
for (const [id, operation] of before)
if (operation.phase === "running" && !operations.some((each) => each.id === id))
this.runtime.notify(OPERATION_TEXT[operation.kind].done);
this.rebuild();
}
/** What a row says about an operation on it: failed, running, or a copy into it whose files are copied. */
private operationText(session: string): string {
const operation =
this.operations.find((each) => each.phase === "failed" && each.session === session) ??
this.operations.find((each) => each.session === session) ??
this.operations.find((each) => each.phase === "running" && each.target === session && each.copying);
if (!operation) return "";
if (operation.phase === "failed") return `${OPERATION_TEXT[operation.kind].failed} `;
return operation.session === session ? `${OPERATION_TEXT[operation.kind].running} ` : "copying files… ";
}
/** `/dismiss-failed`: forgets every failed operation (their lines go from every client's list). */
dismissFailed(): void {
const catalog = this.catalog;
const failed = this.operations.filter((operation) => operation.phase === "failed");
if (!catalog || !failed.length) {
this.runtime.notify("No failed operations");
return;
}
for (const operation of failed)
this.runtime.perform(catalog.dismissOperation({ operation: operation.id }, BACKGROUND_CONTEXT));
}
/** The catalog as shown: the server's rows with the changes in flight. */
private get rows(): Session[] {
return this.shownRows();
}
/** The owner's live catalog, which every change goes through. */
get catalog(): HeldIn<typeof Sessions, "owner"> | undefined {
return liveIn(this.service, "owner");
}
/**
* Changes a row: shown at once, `done` once the server took it (the selector's status, else a status line); a
* refused change rolls back and its error shows as a status line.
*/
edit(guess: RowGuess, call: (catalog: HeldIn<typeof Sessions, "owner">) => Promise<unknown>, done: string): void {
const catalog = this.catalog;
if (!catalog) return;
const settled = this.editing(guess, call(catalog)).then(() => {
if (this.runtime.screen === "sessions") this.setStatus(done);
else this.runtime.notify(done);
});
this.rebuild();
this.runtime.perform(settled.finally(() => this.rebuild()));
}
/** The viewed session, which "current folder" means. */
private get current(): Session | undefined {
return this.rows.find((row) => row.id === this.runtime.session);
@ -262,7 +353,7 @@ export class SessionsSurface implements TuiSurface {
}
command(name: string): void {
if (name === "new" && this.owner && statusOf(this.service()) === "live") {
if (name === "new" && this.catalog) {
this.mode = "create";
const current = this.current;
this.location = new LocationInput(this.ctx, {
@ -298,15 +389,20 @@ export class SessionsSurface implements TuiSurface {
}
}
/**
* Creates a session and opens it at once: this TUI chooses its ID, and the conversation shows loading until the
* session's worker runs. A refused creation comes back to the selector with the error.
*/
private create(location: Location): void {
const service = this.service();
if (statusOf(service) !== "live" || service.variant !== "owner") return;
this.runtime.perform(
service.create({ cwd: location.path, env: location.env }, BACKGROUND_CONTEXT).then((session) => {
if (this.mode === "create") this.closePrompt();
this.runtime.navigate("chat", session.id);
}),
);
const catalog = this.catalog;
if (!catalog) return;
const id = crypto.randomUUID();
if (this.mode === "create") this.closePrompt();
this.runtime.navigate("chat", id);
catalog.create({ id, cwd: location.path, env: location.env }, BACKGROUND_CONTEXT).catch((error: unknown) => {
if (this.runtime.session === id) this.runtime.navigate("sessions", undefined);
this.runtime.fail(error);
});
}
/**
@ -325,18 +421,20 @@ export class SessionsSurface implements TuiSurface {
}
private submitPrompt(): void {
const service = this.service();
if (statusOf(service) !== "live" || service.variant !== "owner") return;
const value = this.prompt.getValue().trim();
if (this.mode === "rename" && this.renaming && value) {
const id = this.renaming.id;
this.runtime.perform(
service.rename({ id, title: value.slice(0, MAX_TITLE) }, BACKGROUND_CONTEXT).then(() => {
this.closePrompt();
this.setStatus("Session renamed");
}),
);
}
if (this.mode !== "rename" || !this.renaming || !value) return;
this.renameSession(this.renaming.id, value);
this.closePrompt();
}
/** Renames session `id` (the selector's prompt, `/name`): the new title shows at once. */
renameSession(id: string, value: string): void {
const title = value.slice(0, MAX_TITLE);
this.edit(
{ id, change: { title } },
(catalog) => catalog.rename({ id, title }, BACKGROUND_CONTEXT),
`Session name: ${lineText(title)}`,
);
}
private closePrompt(): void {
@ -380,13 +478,13 @@ export class SessionsSurface implements TuiSurface {
}
pin(): void {
const row = this.selected();
const service = this.service();
if (row?.kind !== "session" || statusOf(service) !== "live" || service.variant !== "owner") return;
if (row?.kind !== "session") return;
const { id } = row.session;
const pinned = !row.session.pinned;
this.runtime.perform(
service
.pin({ id: row.session.id, pinned }, BACKGROUND_CONTEXT)
.then(() => this.setStatus(pinned ? "Pinned" : "Unpinned")),
this.edit(
{ id, change: { pinned } },
(catalog) => catalog.pin({ id, pinned }, BACKGROUND_CONTEXT),
pinned ? "Pinned" : "Unpinned",
);
}
/** Delete = archive (pi asks to confirm); in the archived scope it restores. */
@ -394,13 +492,12 @@ export class SessionsSurface implements TuiSurface {
const row = this.selected();
if (!this.owner || row?.kind !== "session") return;
if (this.archived) {
const service = this.service();
if (statusOf(service) === "live" && service.variant === "owner")
this.runtime.perform(
service
.restore({ id: row.session.id }, BACKGROUND_CONTEXT)
.then(() => this.setStatus("Session restored")),
);
const { id } = row.session;
this.edit(
{ id, change: { archivedAt: null } },
(catalog) => catalog.restore({ id }, BACKGROUND_CONTEXT),
"Session restored",
);
return;
}
this.confirming = row.session;
@ -438,11 +535,12 @@ export class SessionsSurface implements TuiSurface {
return;
}
if (this.confirming) {
const service = this.service();
if (keys.matches(data, "tui.select.confirm") && statusOf(service) === "live" && service.variant === "owner") {
const id = this.confirming.id;
this.runtime.perform(
service.archive({ id }, BACKGROUND_CONTEXT).then(() => this.setStatus("Session archived")),
if (keys.matches(data, "tui.select.confirm")) {
const { id } = this.confirming;
this.edit(
{ id, change: { archivedAt: Date.now() } },
(catalog) => catalog.archive({ id }, BACKGROUND_CONTEXT),
"Session archived",
);
}
this.confirming = undefined;
@ -556,7 +654,7 @@ export class SessionsSurface implements TuiSurface {
? "warning"
: undefined;
left = color ? theme.fg(color, text) : text;
right = `${this.scopeAll ? `${lineText(where(session))} ` : ""}${this.showIds ? `${session.id} ` : ""}${age(session.updatedAt)}`;
right = `${this.operationText(session.id)}${this.scopeAll ? `${lineText(where(session))} ` : ""}${this.showIds ? `${session.id} ` : ""}${age(session.updatedAt)}`;
}
const available = Math.max(10, width - 2 - visibleWidth(right) - 2);
let styled = truncateToWidth(left, available, "…");
@ -628,9 +726,9 @@ const openSessions: TuiCommand["run"] = (ctx) => ctx.navigate("sessions");
/**
* Moves the viewed session (the web's Relocate, `Sessions.relocate`): the location is resolved and checked as the
* web's DirectoryPicker does (`Envs.listDirectory`, `~` expanded on the session's environment) and relocated to the
* listed path; another environment is confirmed first with the web's note. Errors are the server's text, as on the
* web. Without an argument, a prompt with the environment choice (Tab).
* web's DirectoryPicker does (`Envs.listDirectory`, `~` expanded on the session's environment), then the session row
* shows the listed path at once while the move runs; a refused move (a busy session) rolls back with the server's
* text. Another environment is confirmed first with the web's note.
*/
async function relocate(ctx: TuiContext, session: Session, location: Location, confirmed = false): Promise<void> {
const moving = location.env !== (session.env ?? null);
@ -646,16 +744,15 @@ async function relocate(ctx: TuiContext, session: Session, location: Location, c
return;
const envs = await ownerEnvs(ctx);
if (envs.variant !== "owner") throw new Error("Only the owner changes directories");
const listing = await envs.listDirectory(
const { path } = await envs.listDirectory(
{ path: location.path, ...(location.env ? { env: location.env } : {}) },
BACKGROUND_CONTEXT,
);
await ctx.host.root(async (root) => {
const sessions = await root.get(Sessions, {});
if (sessions.variant !== "owner") throw new Error("Only the owner changes directories");
await sessions.relocate({ id: session.id, env: location.env, cwd: listing.path }, root);
}, BACKGROUND_CONTEXT);
ctx.notify(`Working directory: ${lineText(locationLabel({ env: location.env, path: listing.path }))}`);
// A move is long work (a job): the row says "moving…" until it is done ("Session moved"), or failed.
const catalog = surfaceOf(ctx)?.catalog;
if (!catalog) return;
ctx.notify(`Moving to ${lineText(locationLabel({ env: location.env, path }))}…`);
await catalog.relocate({ id: session.id, env: location.env, cwd: path }, BACKGROUND_CONTEXT);
}
/**
@ -695,9 +792,7 @@ async function moveTo(ctx: TuiContext, env: string | null): Promise<void> {
...(error ? { error } : {}),
onSubmit: (location) => {
overlay.close();
void relocate(ctx, session, location, true).catch((caught: unknown) =>
ctx.notify(caught instanceof Error ? caught.message : String(caught), "error"),
);
void relocate(ctx, session, location, true).catch((caught: unknown) => ctx.fail(caught));
},
onCancel: () => overlay.close(),
}),
@ -733,9 +828,7 @@ async function changeDirectory(ctx: TuiContext, argument: string): Promise<void>
moving: true,
onSubmit: (location) => {
overlay.close();
void relocate(ctx, session, location, true).catch((error: unknown) =>
ctx.notify(error instanceof Error ? error.message : String(error), "error"),
);
void relocate(ctx, session, location, true).catch((error: unknown) => ctx.fail(error));
},
onCancel: () => overlay.close(),
}),
@ -812,22 +905,21 @@ const SESSION_COMMANDS: Record<string, TuiCommand> = {
category: "Session",
when: "chat && owner && !overlay",
slash: { name: "name", argumentHint: "<title>" },
run: async (ctx, args) => {
run: (ctx, args) => {
const session = ctx.identity.session;
const title = args?.trim().slice(0, MAX_TITLE);
const title = args?.trim();
if (!session) return;
if (!title) {
ctx.notify("Usage: /name <title>");
return;
}
await ctx.host.root(async (root) => {
const sessions = await root.get(Sessions, {});
if (sessions.variant !== "owner") throw new Error("Only the owner renames sessions");
await sessions.rename({ id: session, title }, root);
}, BACKGROUND_CONTEXT);
ctx.notify(`Session name: ${title}`);
if (title) surfaceOf(ctx)?.renameSession(session, title);
else ctx.notify("Usage: /name <title>");
},
},
"p2.session.dismissFailed": {
title: "Dismiss failed session operations",
category: "Session",
when: "owner && !overlay",
slash: { name: "dismiss-failed" },
run: (ctx) => surfaceOf(ctx)?.dismissFailed(),
},
"app.session.toggleSort": selectorCommand("Toggle session sort", (surface) => surface.toggleSort()),
"app.session.toggleNamedFilter": selectorCommand("Toggle pinned filter", (surface) => surface.togglePinned()),
"app.session.toggleArchived": selectorCommand(

View file

@ -1,31 +1,41 @@
// The mobile drawer's component tree, with catalog/status/subagent/search handles supplying its data.
// The mobile drawer's component tree, with catalog/status/subagent/search handles supplying its data, and the owner's
// session actions on its rows.
import { Access, BACKGROUND_CONTEXT, contribute, defineFacet, type HeldIn, liveIn, whenLive } from "@p2/app/v1";
import {
ChevronIcon,
closeLayers,
conversationRoute,
Drawer,
MoreIcon,
navigate,
openLayer,
rememberMains,
type SessionSummary,
Slot,
Slots,
toggleSidebar,
useComposerState,
useConnected,
useHost,
useNavigation,
useNewSessionState,
useObject,
useOptimisticCall,
useOwner,
useReadable,
useRoute,
useStatus,
useSheet,
} from "@p2/app/v1/web";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { directoryPickerLayer } from "../envs/contract.web.tsx";
import { Search, SessionReads } from "../search/contract.ts";
import { SessionStatus } from "../status/contract.ts";
import { Subagents } from "../subagents/contract.ts";
import { SessionTree } from "../tree/contract.ts";
import { BranchSheet } from "./BranchSheet.tsx";
import { Sessions } from "./contract.ts";
import { SessionOptions } from "./SessionOptions.tsx";
import { type Operation, type Session, Sessions } from "./contract.ts";
import { SessionSheet } from "./SessionSheet.tsx";
const subscribeVisibility = (listener: () => void) => {
document.addEventListener("visibilitychange", listener);
@ -87,23 +97,80 @@ function FooterMenu() {
}
const footer = <FooterMenu />;
/** What a row says about a lifecycle operation (`Sessions.operations`), by kind: running, and failed. */
const OPERATION_LABELS: Record<Operation["kind"], { running: string; failed: string }> = {
relocate: { running: "Moving…", failed: "Move failed" },
clone: { running: "Cloning…", failed: "Clone failed" },
fork: { running: "Forking…", failed: "Fork failed" },
archive: { running: "Archiving…", failed: "Archive failed" },
};
/**
* The operation row `session` shows: a failed one of it first (with Dismiss), else one running on it, else a copy
* into it whose files are still being copied (its worker waits for them).
*/
function operationOf(
operations: readonly Operation[],
session: string,
dismiss: (operation: string) => void,
): SessionSummary["operation"] {
const failed = operations.find((operation) => operation.phase === "failed" && operation.session === session);
if (failed?.phase === "failed")
return { label: OPERATION_LABELS[failed.kind].failed, error: failed.error, dismiss: () => dismiss(failed.id) };
const running = operations.find((operation) => operation.session === session);
if (running) return { label: OPERATION_LABELS[running.kind].running };
const copying = operations.find(
(operation) => operation.phase === "running" && operation.target === session && operation.copying,
);
return copying ? { label: "Copying files…" } : undefined;
}
/** A change to one session's row while its call is in flight. */
type RowGuess = { readonly id: string; readonly change: Partial<Session> };
const applyRow = (rows: Session[] | undefined, { id, change }: RowGuess): Session[] | undefined =>
rows?.map((row) => (row.id === id ? { ...row, ...change } : row));
/**
* The drawer, and the owner's session actions on its rows: rename, pin, archive and restore (the Session sheet is
* rendered here, opened from the top bar's button), switching and renaming `/tree` branches. Each action shows its
* effect on the rows at once (`useOptimisticCall`); a refused one rolls back and the toast says why.
*/
function Sidebar() {
const connected = useConnected();
const route = useRoute();
const nav = useNavigation();
const service = useObject(Sessions, {});
const catalog = liveIn(service, "owner");
const access = useObject(Access, {});
const guest = useReadable(liveIn(access, "guest")?.grant);
const owner = useStatus(access) === "live" && access?.variant === "owner";
// As the server last said (kept across starts): unread marks and search hold without waiting for Access.
const owner = useOwner();
const status = liveIn(useObject(SessionStatus, {}), "reader");
const agents = liveIn(useObject(Subagents, {}), "reader");
const list = useReadable(liveIn(service, "owner", "guest")?.sessions);
const { fail } = useComposerState();
const [list, edit] = useOptimisticCall(useReadable(liveIn(service, "owner", "guest")?.sessions), applyRow, fail);
const statuses = useReadable(status?.statuses);
const children = useReadable(agents?.children);
useEffect(() => {
if (list) rememberMains(list);
}, [list]);
// Unread answers are the owner's (any device); guests see none and mark nothing.
const marks = useObject(SessionReads, owner ? {} : undefined);
const answers = useReadable(marks?.answers);
const reads = useReadable(marks?.reads);
// Dismissing a failed operation removes its line at once.
const [operations, dismissing] = useOptimisticCall(
useReadable(catalog?.operations) ?? [],
(list, operation: string) => list.filter((each) => each.id !== operation),
fail,
);
const dismiss = useCallback(
(operation: string) => {
if (catalog) dismissing(operation, () => catalog.dismissOperation({ operation }, BACKGROUND_CONTEXT));
},
[catalog, dismissing],
);
const sessions = useMemo(
() =>
(list ?? []).map((session) => {
@ -117,36 +184,52 @@ function Sidebar() {
unread: (answers?.[session.id] ?? 0) > (reads?.[session.id] ?? 0),
},
subagents: children?.[session.id] ?? [],
operation: operationOf(operations, session.id, dismiss),
};
}),
[list, statuses, children, answers, reads],
[list, statuses, children, answers, reads, operations, dismiss],
);
// The open session's main conversation, shown on a visible page, is read through its latest answer.
const visible = usePageVisible();
const shownMain = (list ?? []).find((session) => session.id === route.session)?.main;
const shown = visible && route.conversation === shownMain ? route.session : undefined;
const viewed = list?.find((session) => session.id === route.session);
const shown = visible && route.conversation === viewed?.main ? route.session : undefined;
const latest = shown ? (answers?.[shown] ?? 0) : 0;
const read = shown ? (reads?.[shown] ?? 0) : 0;
useEffect(() => {
if (shown && marks && latest > read)
void marks.markRead({ session: shown, through: latest }, BACKGROUND_CONTEXT).catch(() => {});
}, [marks, shown, latest, read]);
const search = useObject(Search, owner ? {} : undefined);
const search = liveIn(useObject(Search, owner ? {} : undefined), "owner");
// A rebuild shows as running at once; it is long work (a job), so the sheet shows it running until it is done.
const [rebuilds, reindex] = useOptimisticCall(
useReadable(search?.rebuilds) ?? [],
(list, session: string) => [
...list.filter((each) => each.session !== session),
{ session, phase: "running" as const },
],
fail,
);
const onSearch = useCallback(
(query: string, archived: boolean) => search!.search({ query, archived }, BACKGROUND_CONTEXT),
[search],
);
const onRestore = useCallback(
(id: string) =>
catalog?.restore({ id }, BACKGROUND_CONTEXT) ?? Promise.reject(new Error("Only the owner restores sessions")),
[catalog],
);
// A session opens at its main conversation; `/s/<id>` would open conversation 1, which redirects there.
const onOpen = useCallback(
(session: string, conversation?: number) =>
navigate({ session, conversation: conversation ?? list?.find((each) => each.id === session)?.main ?? 1 }),
[list],
);
// The restored row is back at once; the session opens once the server has it (an archived one would bounce).
const onRestore = useCallback(
(id: string, conversation?: number) => {
if (!catalog) return;
edit({ id, change: { archivedAt: null } }, async () => {
await catalog.restore({ id }, BACKGROUND_CONTEXT);
onOpen(id, conversation);
});
},
[catalog, edit, onOpen],
);
// A branch's switch or rename: a reference of its own (the change also reaches the session row).
const host = useHost();
const withTree = useCallback(
@ -162,14 +245,14 @@ function Sidebar() {
},
[host],
);
// Errors go to the app's toast in main, as in Pi Mobile (not clipped inside the drawer).
const { setError } = useComposerState();
// The row marks the chosen branch at once; the view follows once the session's main conversation moved.
const onBranch = useCallback(
(session: string, conversation: number) =>
void withTree(session, (tree) => tree.select({ conversation }, BACKGROUND_CONTEXT))
.then(() => navigate({ session, conversation }))
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : String(reason))),
[withTree, setError],
edit({ id: session, change: { main: conversation } }, async () => {
await withTree(session, (tree) => tree.select({ conversation }, BACKGROUND_CONTEXT));
navigate({ session, conversation });
}),
[withTree, edit],
);
const [renaming, setRenaming] = useState<{ session: string; conversation: number }>();
const onRenameBranch = useCallback(
@ -178,6 +261,19 @@ function Sidebar() {
);
const renamed = renaming && list?.find((each) => each.id === renaming.session);
const branch = renamed?.branches.find((each) => each.conversation === renaming?.conversation);
const renameBranch = (session: Session, conversation: number, label: string | null) =>
edit(
{
id: session.id,
change: {
branches: session.branches.map((each) =>
each.conversation === conversation ? { ...each, label } : each,
),
},
},
() => withTree(session.id, (tree) => tree.rename({ conversation, label }, BACKGROUND_CONTEXT)),
);
const sessionSheet = useSheet("session");
const { setChoice, setLocation } = useNewSessionState();
const onNew = useCallback(() => {
setChoice(undefined);
@ -201,14 +297,13 @@ function Sidebar() {
conversationId={route.conversation}
connected={connected}
onOpen={onOpen}
onBranch={owner ? onBranch : undefined}
onRenameBranch={owner ? onRenameBranch : undefined}
onBranch={catalog ? onBranch : undefined}
onRenameBranch={catalog ? onRenameBranch : undefined}
onNew={onNew}
onNewIn={owner ? onNewIn : undefined}
onCollapse={toggleSidebar}
onSearch={search ? onSearch : undefined}
onRestore={onRestore}
onError={setError}
footer={footer}
onProviders={() => openLayer({ kind: "sheet", name: "providers" }, { fromDrawer: true })}
onEnvs={() => openLayer({ kind: "sheet", name: "envs" }, { fromDrawer: true })}
@ -219,17 +314,62 @@ function Sidebar() {
title={renamed.title}
conversation={branch.conversation}
label={branch.label}
onSave={(label) =>
withTree(renamed.id, (tree) =>
tree.rename({ conversation: branch.conversation, label }, BACKGROUND_CONTEXT),
onSave={(label) => renameBranch(renamed, branch.conversation, label)}
onClose={() => setRenaming(undefined)}
/>
)}
{sessionSheet && catalog && viewed && route.conversation === viewed.main && (
<SessionSheet
session={viewed}
onClose={closeLayers}
onRename={(title) =>
edit({ id: viewed.id, change: { title } }, () =>
catalog.rename({ id: viewed.id, title }, BACKGROUND_CONTEXT),
)
}
onClose={() => setRenaming(undefined)}
onPin={() =>
edit({ id: viewed.id, change: { pinned: !viewed.pinned } }, () =>
catalog.pin({ id: viewed.id, pinned: !viewed.pinned }, BACKGROUND_CONTEXT),
)
}
onShare={() => openLayer({ kind: "sheet", name: "share" })}
onCwd={() => openLayer(directoryPickerLayer(viewed.env ?? null, viewed.cwd))}
reindex={rebuilds.find((each) => each.session === viewed.id)}
onReindex={
search &&
(() => reindex(viewed.id, () => search.rebuildIndex({ session: viewed.id }, BACKGROUND_CONTEXT)))
}
onArchive={() => {
closeLayers();
void nav.replace(conversationRoute(route, undefined));
edit({ id: viewed.id, change: { archivedAt: Date.now() } }, () =>
catalog.archive({ id: viewed.id }, BACKGROUND_CONTEXT),
);
}}
/>
)}
</>
);
}
/** The top bar's button that opens the Session sheet (the drawer renders the sheet, where the rows are). */
function SessionOptions() {
const route = useRoute();
const rows = useReadable(useObject(Sessions, {}, { variant: "owner" })?.sessions);
const session = rows?.find((row) => row.id === route.session);
if (!session || route.conversation !== session.main) return null;
return (
<button
type="button"
className="icon-button"
aria-label="Session options"
onClick={() => openLayer({ kind: "sheet", name: "session" })}
>
<MoreIcon />
</button>
);
}
export default defineFacet((host) => {
contribute(host, "p2.web-sessions", (self) => {
self.contribute(Slots, { sidebar: Sidebar, "session-options": SessionOptions });

View file

@ -7,7 +7,6 @@ import {
} from "@earendil-works/pi-durable";
import { obj, str } from "@earendil-works/pi-schema";
import { defineFacet, expectVariant, forbidden, local } from "@p2/app/v1";
import { durableContext } from "@p2/app/v1/node";
import {
defineP2Tool,
Extensions,
@ -23,8 +22,8 @@ import { MAX_TITLE, Sessions, SessionTransfer } from "./contract.ts";
const SessionTools = local("p2.session-tools", { version: 1, params: {}, members: {} });
/**
* The tool runs outside Chord calls (a Durable tool), so each call starts a fresh short root of the worker's. Only
* top-level conversations (the main conversation and its `/tree` branches) may rename the session.
* The tool runs outside Chord calls (a Durable tool), so each call opens a short root of the worker's. Only top-level
* conversations (the main conversation and its `/tree` branches) may rename the session.
*/
const renameSession = (root: () => WorkerParts["root"]) =>
defineExtension<P2Tool>({
@ -50,9 +49,11 @@ const renameSession = (root: () => WorkerParts["root"]) =>
const title = args.title.replace(/\s+/g, " ").trim();
if (!title) return reply("The title is empty.", true);
try {
// A Durable tool is not a Chord root: each call starts a fresh short one.
const { previous } = await root()(async (ctx) =>
expectVariant(await ctx.get(Sessions, {}), "worker").renameSelf({ title }, ctx),
// A Durable tool is not a Chord call: it pushes the title in a short root of the worker's, under its
// own context.
const { previous } = await root()(
async (ctx) => expectVariant(await ctx.get(Sessions, {}), "worker").renameSelf({ title }, ctx),
context,
);
return reply(
previous === title
@ -77,20 +78,19 @@ export default defineFacet((host) => {
variant: "server",
members: {
history: async ({ conversation, before }, ctx) => {
const context = durableContext(ctx);
const durable = harness().durable;
// A subagent's history as a session of its own needs its cwd and tools: not yet.
if (!(await isTopLevel(durable, conversation as ConversationId, context)))
if (!(await isTopLevel(durable, conversation as ConversationId, ctx)))
throw new Error("Forking a subagent conversation is not supported yet");
const found = await durable.conversation(conversation as ConversationId, context);
const found = await durable.conversation(conversation as ConversationId, ctx);
if (!found) throw new Error(`Unknown conversation ${conversation}`);
const [chosen] = (await found.entries({ maxEntryId: before as EntryId }, 1, undefined, context)).items;
const [chosen] = (await found.entries({ maxEntryId: before as EntryId }, 1, undefined, ctx)).items;
if (chosen?.id !== before) throw new Error("Unknown message");
// The fork holds what came before the chosen message, as the context stood at the entry before it.
const [previous] = (await found.entries({ maxEntryId: (before - 1) as EntryId }, 1, undefined, context))
const [previous] = (await found.entries({ maxEntryId: (before - 1) as EntryId }, 1, undefined, ctx))
.items;
if (!previous) return { entries: [] };
const view = await found.context(context, { at: previous.id });
const view = await found.context(ctx, { at: previous.id });
// Edits are applied in `contributions` (their targets' IDs change); a head marker starts the copy's context.
const entries: EntryDraft[] = view.entries.map((entry, index) => {
const model = view.contributions[index] ?? [];
@ -104,15 +104,14 @@ export default defineFacet((host) => {
return { entries };
},
seed: async ({ entries }, ctx) => {
const context = durableContext(ctx);
const durable = harness().durable;
const root = await durable.conversation(ROOT_CONVERSATION_ID, context);
const root = await durable.conversation(ROOT_CONVERSATION_ID, ctx);
if (!root) throw new Error("No main conversation");
if ((await root.entries({}, 1, undefined, context)).items.length)
if ((await root.entries({}, 1, undefined, ctx)).items.length)
throw new Error("The session has history already");
await durable.commit(async (tx) => {
for (const entry of entries) await tx.appendEntry(ROOT_CONVERSATION_ID, entry as EntryDraft);
}, context);
}, ctx);
return null;
},
},

View file

@ -82,13 +82,10 @@ export const Shares = local("p2.shares", {
/** Guests whose browsers are connected now (server, in memory). */
export const Presence = local("p2.share-presence", {
version: 1,
/** 2: derived from the references to `GuestPresence` (no enter and leave). */
version: 2,
params: {},
members: {
guests: record(ConnectedSchema),
enter: method(ConnectedSchema),
leave: method(obj({ id: str() })),
},
members: { guests: record(ConnectedSchema) },
});
/** Held by a guest's browser for its page's lifetime: marks the guest connected. Nothing for the owner. */

View file

@ -53,11 +53,21 @@ export type PersonRow = {
readonly mode: ShareMode;
};
/** A grant to a person by email that is not removed: the email (normalized) and the session. */
export type EmailGrant = { readonly email: string; readonly session: string };
/** The emails of `grants` on sessions `open` accepts, each once, sorted. */
export function openEmails(grants: readonly EmailGrant[], open: (session: string) => boolean): string[] {
return [...new Set(grants.filter((grant) => open(grant.session)).map((grant) => grant.email))].sort();
}
/** Thrown for join failures the guest may see. */
export class JoinError extends Error {}
/** The shares database (`shares.sqlite`); listeners hear of every committed change of links, guests and grants. */
export class ShareDb {
readonly #db: DatabaseSync;
readonly #listeners = new Set<() => void>();
constructor(directory: string) {
const path = join(directory, "shares.sqlite");
@ -94,16 +104,24 @@ export class ShareDb {
});
}
/** `listener` hears of every committed change from now on; the result stops it. */
subscribe(listener: () => void): () => void {
this.#listeners.add(listener);
return () => this.#listeners.delete(listener);
}
#transaction<T>(fn: () => T): T {
this.#db.exec("BEGIN");
let result: T;
try {
const result = fn();
result = fn();
this.#db.exec("COMMIT");
return result;
} catch (error) {
this.#db.exec("ROLLBACK");
throw error;
}
for (const listener of this.#listeners) listener();
return result;
}
links(): ShareLink[] {
@ -279,16 +297,19 @@ export class ShareDb {
}));
}
/** Every email with a grant that is not removed, on a session `open` accepts; sorted. */
emails(open: (session: string) => boolean): string[] {
const emails = new Set<string>();
for (const row of this.#db
/** Every grant to a person by email that is not removed. */
emailGrants(): EmailGrant[] {
return this.#db
.prepare(
"SELECT guests.email,grants.session FROM grants JOIN guests ON guests.id=grants.guest WHERE grants.removed=0 AND guests.email IS NOT NULL",
)
.all())
if (open(String(row.session))) emails.add(String(row.email));
return [...emails].sort();
.all()
.map((row) => ({ email: String(row.email), session: String(row.session) }));
}
/** Every email with a grant that is not removed, on a session `open` accepts; sorted. */
emails(open: (session: string) => boolean): string[] {
return openEmails(this.emailGrants(), open);
}
seen(id: string): void {

View file

@ -7,15 +7,14 @@ import {
type Ctx,
contribute,
defineFacet,
defineState,
defineTypedState,
deriveTyped,
followNative,
forbidden,
type GuestPrincipal,
isGuest,
isOwner,
type P2Principal,
record,
} from "@p2/app/v1";
import {
Authenticators,
@ -30,22 +29,11 @@ import {
SessionAccess,
ShareUrls,
sendJson,
serverOf,
} from "@p2/app/v1/server";
import { Catalog } from "../sessions/contract.ts";
import { ChildStore, remoteEnvironments } from "../subagents/contract.ts";
import {
ConnectedSchema,
GuestPresence,
LinkSchema,
PersonSchema,
Presence,
type ShareLink,
type ShareMode,
Shares,
Sharing,
} from "./contract.ts";
import { type GuestRow, JoinError, ShareDb } from "./db.ts";
import { GuestPresence, Presence, type ShareLink, type ShareMode, Shares, Sharing } from "./contract.ts";
import { type GuestRow, JoinError, openEmails, ShareDb } from "./db.ts";
/** Registers the join routes, the guest cookie and Radius authenticators, and guest session access. */
@ -102,24 +90,11 @@ async function readJson(request: IncomingMessage, limit: number): Promise<unknow
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}
/** The database's links, emails and people as `Shares` shows them, and the archived sessions as last seen. */
const Shown = defineTypedState("p2.shares", {
schema: obj({
/** Null before the catalog was first seen. */
archived: union([arr(str()), nul()]),
links: arr(LinkSchema),
emails: arr(str()),
people: arr(PersonSchema),
}),
/** The archived sessions as the routine below last saw them (null before it first saw the catalog). */
const Archived = defineTypedState("p2.shares.archived", {
schema: obj({ sessions: union([arr(str()), nul()]) }),
version: 1,
initial: () => ({ archived: null, links: [], emails: [], people: [] }),
});
/** The connected guests (in memory). */
const Connected = defineState("p2.presence", {
schema: obj({ guests: record(ConnectedSchema) }),
version: 1,
initial: () => ({ guests: {} }),
initial: () => ({ sessions: null }),
});
export default defineFacet((host) => {
@ -127,26 +102,22 @@ export default defineFacet((host) => {
const catalog = self.get(Catalog, {});
const children = self.get(ChildStore, {});
const server = self.get(Server, {});
const shown = self.state(Shown);
const unarchived = (session: string): boolean => !shown.value.archived?.includes(session);
const reload = (ctx: Ctx) =>
shown.change((draft) => {
draft.links = dbResource.value.links();
draft.emails = dbResource.value.emails(unarchived);
draft.people = dbResource.value.people();
}, ctx);
const archived = self.state(Archived);
const dbResource = self.open(
async (ctx) => {
const db = new ShareDb(server.info.value.dataDir);
await shown.change((draft) => {
draft.links = db.links();
draft.emails = db.emails(unarchived);
draft.people = db.people();
}, ctx);
return db;
},
async () => new ShareDb(server.info.value.dataDir),
(db) => db.close(),
);
// The database's links, people and email grants as it holds them: a method's write tells this source before the
// method returns.
const stored = followNative(
self,
(changed) => dbResource.value.subscribe(changed),
() => ({
links: dbResource.value.links(),
people: dbResource.value.people(),
emails: dbResource.value.emailGrants(),
}),
);
/** The session is known and not archived. */
const open = async (session: string, ctx: Ctx): Promise<boolean> => {
const found = await catalog.get({ id: session }, ctx).catch(() => undefined);
@ -188,24 +159,24 @@ export default defineFacet((host) => {
};
// Archive ends every guest's access to the session, restore gives it back: both change their grants.
self.on(catalog.sessions, async ({ value: sessions }, ctx) => {
const archived = sessions.filter((session) => session.archivedAt !== null).map((session) => session.id);
const previous = shown.value.archived;
await shown.change((draft) => {
draft.archived = archived;
}, ctx);
await shown.change((draft) => {
draft.emails = dbResource.value.emails(unarchived);
const now = sessions.filter((session) => session.archivedAt !== null).map((session) => session.id);
const previous = archived.value.sessions;
await archived.change((draft) => {
draft.sessions = now;
}, ctx);
if (previous === null) return;
const before = new Set(previous);
const after = new Set(archived);
const changed = [...archived.filter((id) => !before.has(id)), ...previous.filter((id) => !after.has(id))];
const after = new Set(now);
const changed = [...now.filter((id) => !before.has(id)), ...previous.filter((id) => !after.has(id))];
for (const session of changed) await revoke(dbResource.value.bump(session), ctx);
});
return {
links: deriveTyped(self, [shown], ([value]) => value.links),
emails: self.derive([shown], ([value]) => value.emails),
people: deriveTyped(self, [shown], ([value]) => value.people),
links: deriveTyped(self, [stored], ([value]) => value.links),
emails: self.derive([stored, catalog.sessions], ([{ emails }, sessions]) => {
const archivedNow = new Set(sessions.filter((entry) => entry.archivedAt !== null).map((entry) => entry.id));
return openEmails(emails, (session) => !archivedNow.has(session));
}),
people: deriveTyped(self, [stored], ([value]) => value.people),
authenticate: async ({ cookie }, ctx) => {
const principal = await principalOf(dbResource.value.guestOfCookie(cookie), ctx);
if (principal) dbResource.value.seen(principal.id);
@ -223,9 +194,7 @@ export default defineFacet((host) => {
},
link: async ({ session, mode, reset, remoteConfirmed }, ctx) => {
await confirmRemote(session, mode, remoteConfirmed, ctx);
const ended = dbResource.value.link(session, mode, reset);
await reload(ctx);
await revoke(ended, ctx);
await revoke(dbResource.value.link(session, mode, reset), ctx);
return null;
},
share: async ({ session, email, mode, remoteConfirmed }, ctx) => {
@ -233,13 +202,11 @@ export default defineFacet((host) => {
if (!normalized) throw new Error("Enter an email address");
await confirmRemote(session, mode, remoteConfirmed, ctx);
const shared = dbResource.value.share(session, normalized, email.trim(), mode);
await reload(ctx);
if (shared.changed) await revoke([shared.guest], ctx);
return null;
},
remove: async ({ session, guestId }, ctx) => {
if (dbResource.value.remove(session, guestId)) await revoke([guestId], ctx);
await reload(ctx);
return null;
},
join: async ({ token, name, cookie }, ctx) => {
@ -255,38 +222,21 @@ export default defineFacet((host) => {
};
});
host.serve(Presence, (_params, self) => {
const connected = self.state(Connected);
return {
guests: self.derive([connected], ([value]) => value.guests),
enter: async (guest, ctx) => {
await connected.change((draft) => {
draft.guests[guest.id] = guest;
}, ctx);
return null;
},
leave: async ({ id }, ctx) => {
await connected.change((draft) => {
delete draft.guests[id];
}, ctx);
return null;
},
};
});
// The guests connected now: every guest principal holding a `GuestPresence` (one per tab; a link drop is a leaving).
const holders = host.references(GuestPresence);
host.serve(Presence, (_params, self) => ({
guests: self.derive([holders], ([references]) => {
const guests: Record<string, { id: string; name: string; sessions: Record<string, ShareMode> }> = {};
for (const { principal } of references)
if (isGuest(principal))
guests[principal.id] = { id: principal.id, name: principal.name, sessions: principal.sessions };
return guests;
}),
}));
host.serve(GuestPresence, (_params, principal, self) => {
host.serve(GuestPresence, (_params, principal) => {
if (isOwner(principal)) return { variant: "owner", members: {} };
if (!isGuest(principal)) throw forbidden("owners and guests only");
const presence = self.get(Presence, {});
const server = serverOf(self);
const { id, name, sessions } = principal;
// One object per guest revision: live while any of the guest's tabs holds it.
self.open(
(ctx) => presence.enter({ id, name, sessions }, ctx),
async () => {
await server().root((ctx) => presence.leave({ id }, ctx));
},
);
return { variant: "guest", members: {} };
});

View file

@ -79,15 +79,8 @@ function openShare(ctx: TuiContext): void {
)
return undefined;
await service.link({ mode, ...(remote ? { remoteConfirmed: true } : {}) }, BACKGROUND_CONTEXT);
// The new link arrives as state.
return new Promise<string | undefined>((resolve) => {
const stop = follow(service.links, (value) => {
const found = value?.find((link) => link.mode === mode)?.url;
if (found === undefined) return;
queueMicrotask(() => stop());
resolve(found);
});
});
// The answer follows its state: the new link is in `links` when the call resolves.
return service.links.value.find((link) => link.mode === mode)?.url;
};
const copy = async (mode: ShareMode) => {
status = "";

View file

@ -1,9 +1,11 @@
// Sharing in the browser: the share sheet (owner), and the guest's presence while its page is open.
import { BACKGROUND_CONTEXT, contribute, defineFacet, follow } from "@p2/app/v1";
import { BACKGROUND_CONTEXT, contribute, defineFacet } from "@p2/app/v1";
import {
closeLayers,
copyText,
errorText,
readValue,
Sheet,
Slots,
useObject,
@ -17,8 +19,6 @@ import { GuestPresence, type ShareMode, Sharing } from "./contract.ts";
const LABEL: Record<ShareMode, string> = { view: "Read-only", full: "Read/write" };
const errorText = (reason: unknown): string => (reason instanceof Error ? reason.message : String(reason));
/**
* Two links per session (read-only; read/write: send and Stop), the people it is shared with by email, and the guests
* connected now. People are offered only while the brain is connected to Radius (`peopleUrl`): they open the session
@ -54,15 +54,8 @@ function ShareSheet({ session, onClose }: { session: string; onClose: () => void
}
if (url === undefined) {
await sharing.link({ mode, ...(remoteConfirmed ? { remoteConfirmed } : {}) }, BACKGROUND_CONTEXT);
// The new link arrives as state; wait for it (the sheet is subscribed).
url = await new Promise<string | undefined>((resolve) => {
const stop = follow(sharing.links, (value) => {
const found = value?.find((link) => link.mode === mode)?.url;
if (found === undefined) return;
queueMicrotask(() => stop());
resolve(found);
});
});
// The answer follows its state: the new link is in `links` when the call resolves.
url = readValue(sharing.links)?.find((link) => link.mode === mode)?.url;
}
if (url !== undefined && (await copyText(url))) setCopied(mode);
} catch (reason) {

View file

@ -15,12 +15,13 @@ export const StatusSchema = obj({
export type Status = Static<typeof StatusSchema>;
export const StatusesSchema = record(StatusSchema);
const report = {
/** What a worker reports of its work: its live tasks, its busy conversations and its spend. */
export const ReportFields = {
tasks: num(),
busy: arr(num()),
usage: opt(unsafe<Usage>(unknown())),
};
const ReportSchema = obj(report);
const ReportSchema = obj(ReportFields);
/** Live status of every session (server, in memory). Reports are fenced by worker generation. */
export const StatusStore = local("p2.status-store", {
@ -28,7 +29,7 @@ export const StatusStore = local("p2.status-store", {
params: {},
members: {
statuses: StatusesSchema,
report: method(obj({ ...report, session: str(), generation: num() })),
report: method(obj({ ...ReportFields, session: str(), generation: num() })),
},
});
@ -43,7 +44,21 @@ export const SessionStatus = remote("p2.session-status", {
},
});
/** Owner-only retry of a failed session worker; reading status never starts it. */
/**
* The session worker's status push (worker, local): reports the worker's work now (its tasks, busy conversations and
* spend) to the server and returns once the server committed it. A method that starts or stops work (`Chat.send`,
* `Chat.stop`) awaits it, so the sidebar's working dot is current when the method answers.
*/
export const StatusReport = local("p2.status-report", {
version: 1,
params: {},
members: { push: method() },
});
/**
* Owner-only retry of a failed session worker; reading status never starts it. `restart` answers once the new worker
* runs, or fails with why it could not start (`worker_failed`).
*/
export const WorkerControl = remote("p2.worker-control", {
version: 1,
params: { session: param.str() },
@ -51,16 +66,10 @@ export const WorkerControl = remote("p2.worker-control", {
variants: { owner: { methods: { restart: method() } } },
});
/** Browser pages hold one unique lease for their active session, including child views. */
export const Viewers = local("p2.session-viewers", {
version: 1,
params: {},
members: {
counts: record(num()),
enter: method(obj({ session: str(), viewer: str() })),
leave: method(obj({ session: str(), viewer: str() })),
},
});
/**
* What a page shows about its session: how many viewers (distinct `viewer` params holding it: a page holds one for its
* active session, child views included) and the session's spend.
*/
export const SessionMetrics = remote("p2.session-metrics", {
version: 1,
params: { session: param.str(), viewer: param.str({ min: 1, max: 80 }) },

View file

@ -2,68 +2,58 @@ import { num, obj } from "@earendil-works/pi-schema";
import {
canView,
defineFacet,
defineState,
defineTypedState,
deriveTyped,
forbidden,
isGuest,
isOwner,
isWorker,
PluginError,
record,
} from "@p2/app/v1";
import { Hosts, serverOf } from "@p2/app/v1/server";
import {
SessionMetrics,
SessionStatus,
type Status,
StatusesSchema,
StatusStore,
Viewers,
WorkerControl,
} from "./contract.ts";
import { ReportFields, SessionMetrics, SessionStatus, type Status, StatusStore, WorkerControl } from "./contract.ts";
const Statuses = defineTypedState("p2.status-store", {
schema: obj({ statuses: StatusesSchema }),
/** The latest report of each session's worker, with the generation that sent it. */
const Reports = defineTypedState("p2.status-reports", {
schema: obj({ reports: record(obj({ generation: num(), ...ReportFields })) }),
version: 1,
initial: () => ({ statuses: {} }),
});
const Counts = defineState("p2.session-viewers", {
schema: obj({ counts: record(num()) }),
version: 1,
initial: () => ({ counts: {} }),
initial: () => ({ reports: {} }),
});
export default defineFacet((host) => {
// Who looks at a session: one entry per `SessionMetrics` reference (its params name the session and the viewer).
const metrics = host.references(SessionMetrics);
host.serve(StatusStore, (_params, self) => {
const store = self.state(Statuses);
const reports = self.state(Reports);
const hosts = self.get(Hosts, {});
// Whether a worker runs comes from the workers component; what it does, from the worker's reports.
self.on(hosts.workers, async ({ value: workers }, ctx) => {
await store.change((draft) => {
for (const [session, worker] of Object.entries(workers)) {
const current = draft.statuses[session];
if (!worker.running) {
draft.statuses[session] = {
running: false,
tasks: 0,
busy: [],
...(worker.error ? { error: worker.error } : {}),
};
} else if (current?.running !== true || current.error)
draft.statuses[session] = { running: true, tasks: 0, busy: [] };
}
}, ctx);
});
// Whether a worker runs comes from the workers component, what it does from its own generation's latest report.
const statuses = deriveTyped(self, [hosts.workers, reports], ([workers, { reports: latest }]) =>
Object.fromEntries(
Object.entries(workers).map(([session, worker]): [string, Status] => {
if (!worker.running)
return [
session,
{ running: false, tasks: 0, busy: [], ...(worker.error ? { error: worker.error } : {}) },
];
const report = latest[session];
if (report === undefined || report.generation !== worker.generation)
return [session, { running: true, tasks: 0, busy: [] }];
const { generation: _generation, ...work } = report;
return [session, { running: true, ...work }];
}),
),
);
return {
statuses: deriveTyped(self, [store], ([value]) => value.statuses as Record<string, Status>),
statuses,
report: async ({ session, generation, tasks, busy, usage }, ctx) => {
// A report of an older worker (one that exited while its report travelled) is dropped.
const worker = hosts.workers.value[session];
if (worker === undefined || !worker.running || worker.generation !== generation) return null;
await store.change((draft) => {
draft.statuses[session] = {
running: true,
await reports.change((draft) => {
draft.reports[session] = {
generation,
tasks,
busy: [...busy].sort((a, b) => a - b),
...(usage ? { usage } : {}),
@ -80,53 +70,26 @@ export default defineFacet((host) => {
return {
variant: "owner",
members: {
restart: async () => {
await server().workers.retry(session);
// Answers with the start's outcome: the worker runs, or the call fails with why it could not start.
restart: async (ctx) => {
const outcome = await server().workers.retry(session, ctx);
if (outcome.error !== undefined) throw new PluginError("worker_failed", outcome.error);
return null;
},
},
};
});
host.serve(Viewers, (_params, self) => {
const viewers = new Map<string, Set<string>>();
const counts = self.state(Counts);
return {
counts: self.derive([counts], ([value]) => value.counts),
enter: async ({ session, viewer }, ctx) => {
const list = viewers.get(session) ?? new Set<string>();
list.add(viewer);
viewers.set(session, list);
await counts.change((draft) => {
draft.counts[session] = list.size;
}, ctx);
return null;
},
leave: async ({ session, viewer }, ctx) => {
const list = viewers.get(session);
list?.delete(viewer);
await counts.change((draft) => {
draft.counts[session] = list?.size ?? 0;
}, ctx);
return null;
},
};
});
host.serve(SessionMetrics, ({ session, viewer }, principal, self) => {
host.serve(SessionMetrics, ({ session }, principal, self) => {
if (!canView(principal, session)) throw forbidden("viewers only");
const counts = self.get(Viewers, {});
const status = self.get(StatusStore, {});
const server = serverOf(self);
const clients = self.derive([counts.counts], ([value]) => value[session] ?? 0);
const usage = deriveTyped(self, [status.statuses], ([value]) => value[session]?.usage ?? null);
self.open(
async () => server().root((ctx) => counts.enter({ session, viewer }, ctx)),
async () => {
await server()
.root((ctx) => counts.leave({ session, viewer }, ctx))
.catch(() => {});
},
const clients = self.derive(
[metrics],
([references]) =>
new Set(references.filter((each) => each.params.session === session).map((each) => each.params.viewer))
.size,
);
const usage = deriveTyped(self, [status.statuses], ([value]) => value[session]?.usage ?? null);
return { variant: "reader", members: { clients, usage } };
});

View file

@ -268,14 +268,16 @@ export default tuiFacet("footer", (runtime) => new FooterSurface(runtime), {
category: "Owner",
when: "owner && !overlay",
slash: { name: "retry-worker" },
// Long work: the pending line shows at once, the outcome when the worker started (or failed again).
run: (ctx) => {
const session = ctx.identity.session;
if (!session) return;
ctx.notify("Retrying the session worker…");
return ctx.host.root(async (root) => {
const control = await root.get(WorkerControl, { session });
if (control.variant !== "owner") return;
await control.restart(root);
ctx.notify("Retrying the session worker");
ctx.notify("Session worker started");
}, BACKGROUND_CONTEXT);
},
},

View file

@ -1,10 +1,10 @@
// The session worker's part of status: its Durable task graph, followed, and pushed to the server's index of statuses
// (the sidebar's working dot). The worker pushes; the server never asks.
import type { Usage } from "@earendil-works/pi-ai";
import type { TaskGraph } from "@earendil-works/pi-durable";
import { contribute, defineFacet, expectVariant } from "@p2/app/v1";
import { type Ctx, defineFacet, expectVariant } from "@p2/app/v1";
import { harnessOf, workerOf } from "@p2/app/v1/worker";
import { SessionStatus } from "./contract.ts";
/** Reports the worker's Durable task graph to the server (status) and to the host manager (liveness). */
import { SessionStatus, StatusReport } from "./contract.ts";
/** Live tasks, and conversations with foreground work: a background reporter does not make a conversation busy. */
function summarize(graph: TaskGraph): { tasks: number; busy: number[] } {
@ -13,34 +13,53 @@ function summarize(graph: TaskGraph): { tasks: number; busy: number[] } {
return { tasks: nodes.length, busy: busy.sort((a, b) => a - b) };
}
/** The session's spend: every model and tool bucket summed, or undefined before the first one. */
function totalUsage(totals: { models: Record<string, Usage>; tools: Record<string, Usage> }): Usage | undefined {
const buckets = [...Object.values(totals.models), ...Object.values(totals.tools)];
if (!buckets.length) return undefined;
const usage: Usage = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
for (const bucket of buckets) {
for (const key of ["input", "output", "cacheRead", "cacheWrite", "totalTokens"] as const)
usage[key] += bucket[key];
for (const key of ["input", "output", "cacheRead", "cacheWrite", "total"] as const)
usage.cost[key] += bucket.cost[key];
}
return usage;
}
export default defineFacet((host) => {
contribute(host, "p2.status-reporter", (self) => {
host.serve(StatusReport, (_params, self) => {
const harness = harnessOf(self);
const worker = workerOf(self);
const status = self.get(SessionStatus, {});
const graph = self.follow((observer, ctx) => harness().durable.observeTaskGraph(observer, ctx));
self.on(graph, async ({ value }, ctx) => {
worker().liveness.setDurableTasks(summarize(value as TaskGraph).tasks);
const totals = await harness().durable.usage(ctx);
const usage: Usage = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
const buckets = [...Object.values(totals.models), ...Object.values(totals.tools)];
for (const bucket of buckets) {
for (const key of ["input", "output", "cacheRead", "cacheWrite", "totalTokens"] as const)
usage[key] += bucket[key];
for (const key of ["input", "output", "cacheRead", "cacheWrite", "total"] as const)
usage.cost[key] += bucket.cost[key];
}
await expectVariant(status, "reporter").report(
{ ...summarize(value as TaskGraph), ...(buckets.length ? { usage } : {}) },
ctx,
);
});
/** The push running now: pushes run one after another, each reading the graph when it starts. */
let last: Promise<unknown> = Promise.resolve();
const report = async (ctx: Ctx): Promise<void> => {
const summary = summarize(graph.value as TaskGraph);
worker().liveness.setDurableTasks(summary.tasks);
const usage = totalUsage(await harness().durable.usage(ctx));
await expectVariant(status, "reporter").report({ ...summary, ...(usage ? { usage } : {}) }, ctx);
};
const push = (ctx: Ctx): Promise<void> => {
const run = last.then(() => report(ctx));
last = run.catch(() => {});
return run;
};
// Work the agent starts on its own (a turn continuing, a subagent's report) is pushed as the graph changes.
self.on(graph, async (_call, ctx) => push(ctx));
return {
push: async (ctx) => {
await push(ctx);
return null;
},
};
});
});

View file

@ -3,7 +3,7 @@
// session selector's subagent conversations (threaded sort, p2-tui-port.md section 1.17).
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
import { BACKGROUND_CONTEXT, contribute, defineFacet, follow, locationText, statusOf, whenLive } from "@p2/app/v1";
import { BACKGROUND_CONTEXT, contribute, defineFacet, liveIn, locationText } from "@p2/app/v1";
import type { TuiContext } from "@p2/app/v1/tui";
import {
getTextOutput,
@ -111,27 +111,24 @@ function openAgents(ctx: TuiContext): void {
ctx.notify("Open a session first");
return;
}
const ref = ctx.host.hold(Subagents, {}, BACKGROUND_CONTEXT);
let children: Child[] = [];
let showArchived = false;
let closed = false;
let stop = (): void => {};
// The children are the reader variant's: subscribed once the reference knows its variant.
void whenLive(ref)
.then(() => {
if (closed || ref.variant !== "reader") return;
stop = follow(ref.children, (value) => {
children = value[session] ?? [];
ctx.requestRender();
const scope = ctx.scope();
let received: Child[] = [];
scope.watch(Subagents, {}, (ref, follow) => {
if (ref.variant === "reader")
follow(ref.children, (value) => {
received = value[session] ?? [];
});
})
.catch(() => {});
const control = ctx.host.hold(SubagentControl, { session }, BACKGROUND_CONTEXT);
});
const control = scope.watch(SubagentControl, { session }, () => {});
// Archive and restore show at once; a refused one flips back with the error.
const [shown, archiving] = scope.optimistic(
() => received,
(children, { conversation, archived }: { conversation: number; archived: boolean }) =>
children.map((child) => (child.conversationId === conversation ? { ...child, archived } : child)),
);
let showArchived = false;
const close = () => {
closed = true;
stop();
ref.release();
control.release();
scope.dispose();
overlay.close();
};
const rows = (): ListRow[] => [
@ -140,7 +137,7 @@ function openAgents(ctx: TuiContext): void {
label: "main",
detail: ctx.identity.conversation === ctx.identity.main ? "✓ shown" : `conversation ${ctx.identity.main}`,
},
...children
...shown()
.filter((child) => showArchived || !child.archived)
.map((child) => ({
key: String(child.conversationId),
@ -172,18 +169,19 @@ function openAgents(ctx: TuiContext): void {
id: "app.session.delete",
label: "archive/restore",
run: (row) => {
const child = children.find((candidate) => String(candidate.conversationId) === row?.key);
const child = shown().find((candidate) => String(candidate.conversationId) === row?.key);
if (!child || child.kind === "agent") return;
if (statusOf(control) !== "live" || control.variant !== "participant") {
const participant = liveIn(control, "participant");
if (!participant) {
ctx.notify("Only participants archive subagents", "warning");
return;
}
void control
.archive({ conversation: child.conversationId, archived: !child.archived }, BACKGROUND_CONTEXT)
.then(() => ctx.notify(`${child.archived ? "Restored" : "Archived"} ${child.name}`))
.catch((error: unknown) =>
ctx.notify(error instanceof Error ? error.message : String(error), "error"),
);
const change = { conversation: child.conversationId, archived: !child.archived };
ctx.perform(
archiving(change, participant.archive(change, BACKGROUND_CONTEXT)).then(() =>
ctx.notify(`${change.archived ? "Archived" : "Restored"} ${child.name}`),
),
);
},
},
],

View file

@ -1,8 +1,17 @@
import { setTimeout as sleep } from "node:timers/promises";
import type { Models } from "@earendil-works/pi-ai/models";
import { type ConversationId, ROOT_CONVERSATION_ID } from "@earendil-works/pi-durable";
import { canSend, contribute, defineFacet, expectVariant, forbidden, signalOf } from "@p2/app/v1";
import { durableContext, ExtensionSwitches } from "@p2/app/v1/node";
import { type ConversationId, type Harness, ROOT_CONVERSATION_ID } from "@earendil-works/pi-durable";
import {
type Ctx,
canSend,
contribute,
defineFacet,
defineRegistry,
expectVariant,
forbidden,
signalOf,
} from "@p2/app/v1";
import { ExtensionSwitches } from "@p2/app/v1/node";
import {
CommitSignal,
ConversationConfigured,
@ -32,7 +41,58 @@ import {
const READ_ONLY = "One-shot agents are read-only";
/** Pushes the session's child list to the server, one push at a time, each reading the registry when it starts. */
class ChildReporter {
readonly #report: (ctx: Ctx) => Promise<void>;
#last: Promise<unknown> = Promise.resolve();
constructor(report: (ctx: Ctx) => Promise<void>) {
this.#report = report;
}
/** Resolves once the server committed a report read after every change made before this call. */
push(ctx: Ctx): Promise<void> {
const run = this.#last.then(() => this.#report(ctx));
this.#last = run.catch(() => {});
return run;
}
}
/** The worker's child reporter, for methods that push what they changed (contributed by the tools' object). */
const ChildReports = defineRegistry<ChildReporter, ChildReporter | undefined>("p2.subagents.reports", { version: 1 });
/** The session's subagents and one-shot agents as the server's index lists them. */
async function childrenOf(durable: Harness, ctx: Ctx): Promise<Child[]> {
const registry = await durable.snapshot(Registry, ROOT_CONVERSATION_ID, ctx);
const children: Child[] = [];
for (const [name, agent] of Object.entries(registry?.agents ?? {})) {
const child = await durable.conversation(agent.conversationId, ctx);
const latest = await child?.entries({}, 1, undefined, ctx);
children.push({
name,
conversationId: agent.conversationId,
model: agent.model,
thinkingLevel: agent.thinkingLevel,
tools: [...agent.tools],
...(agent.location === undefined ? {} : { location: agent.location }),
lastEntryId: latest?.items[0]?.id ?? 0,
...(agent.archived ? { archived: true } : {}),
});
}
for (const [callId, agent] of Object.entries(registry?.oneShots ?? {}))
children.push({
name: agent.label,
kind: "agent",
callId,
conversationId: agent.conversationId,
model: agent.model,
thinkingLevel: agent.thinkingLevel,
tools: [...agent.tools],
lastEntryId: 0,
});
return children;
}
export default defineFacet((host, self) => {
host.serve(ChildReports, { compose: (table) => table[0]?.value });
// Subagents spawned without a cwd and one-shot agents follow the session: the opening Harness gets this (without a
// Harness dependency) and moves them with it. Only a subagent's own registry `location` keeps it elsewhere.
self.contribute(ConversationLocations, {
@ -81,13 +141,12 @@ export default defineFacet((host, self) => {
.sort(),
);
self.on(scriptTools, async ({ value }, ctx) => {
await enableScriptTools(harness().durable, value, durableContext(ctx));
await enableScriptTools(harness().durable, value, ctx);
});
self.contribute(ConversationConfigured, {
subagents: async (conversation, ctx) => {
if (conversation.id === ROOT_CONVERSATION_ID) return;
const context = durableContext(ctx);
const agent = await conversation.agent(context);
const agent = await conversation.agent(ctx);
await harness().durable.commit(async (tx) => {
const registry = await tx.doc(Registry, ROOT_CONVERSATION_ID);
const child = Object.values(registry.agents).find((entry) => entry.conversationId === conversation.id);
@ -95,23 +154,23 @@ export default defineFacet((host, self) => {
child.model = agent.model ? `${agent.model.provider}/${agent.model.modelId}` : child.model;
child.thinkingLevel = agent.thinkingLevel;
child.tools = choosableTools(agent, access()).map((tool) => tool.name);
}, context);
}, ctx);
},
});
// A one-shot agent belongs to the script waiting for its answer: it can be read and stopped, not steered.
self.contribute(ReadOnlyConversations, {
subagents: async (conversation, ctx) => {
const registry = await harness().durable.snapshot(Registry, ROOT_CONVERSATION_ID, durableContext(ctx));
const registry = await harness().durable.snapshot(Registry, ROOT_CONVERSATION_ID, ctx);
return Object.values(registry?.oneShots ?? {}).some((agent) => agent.conversationId === conversation)
? READ_ONLY
: undefined;
},
});
// Only the sidebar orders by activity, and it lists no one-shot agents: their entries do not report.
const oneShots = new Set<number>();
const commits = self.open(
async () => {
// Only the sidebar orders by activity, and it lists no one-shot agents: their entries do not report.
const oneShots = new Set<number>();
const signal = new CommitSignal(harness().durable, (publication) =>
async () =>
new CommitSignal(harness().durable, (publication) =>
publication.changes.some(
(change) =>
(change.type === "document" && change.record.kind === Registry.definition.kind) ||
@ -119,49 +178,24 @@ export default defineFacet((host, self) => {
change.value.conversationId !== ROOT_CONVERSATION_ID &&
!oneShots.has(change.value.conversationId)),
),
);
return { signal, oneShots };
},
({ signal }) => signal.close(),
),
(signal) => signal.close(),
);
const reporter = new ChildReporter(async (ctx) => {
const children = await childrenOf(harness().durable, ctx);
for (const child of children) if (child.kind === "agent") oneShots.add(child.conversationId);
await expectVariant(feed, "reporter").report({ children }, ctx);
});
self.contribute(ChildReports, reporter);
// What the agent changes on its own (a spawn, a subagent's messages moving it up the list) is pushed after each
// commit that touches it; methods push their own changes before they answer (`SubagentControl.archive`).
self.onLive(async (ctx) => {
const durable = harness().durable;
const { signal, oneShots } = commits.value;
const signal = commits.value;
const aborted = signalOf(ctx);
while (!aborted.aborted) {
const seen = signal.version;
try {
const context = durableContext(ctx);
const registry = await durable.snapshot(Registry, ROOT_CONVERSATION_ID, context);
const children: Child[] = [];
for (const [name, agent] of Object.entries(registry?.agents ?? {})) {
const child = await durable.conversation(agent.conversationId, context);
const latest = await child?.entries({}, 1, undefined, context);
children.push({
name,
conversationId: agent.conversationId,
model: agent.model,
thinkingLevel: agent.thinkingLevel,
tools: [...agent.tools],
...(agent.location === undefined ? {} : { location: agent.location }),
lastEntryId: latest?.items[0]?.id ?? 0,
...(agent.archived ? { archived: true } : {}),
});
}
for (const [callId, agent] of Object.entries(registry?.oneShots ?? {})) {
oneShots.add(agent.conversationId);
children.push({
name: agent.label,
kind: "agent",
callId,
conversationId: agent.conversationId,
model: agent.model,
thinkingLevel: agent.thinkingLevel,
tools: [...agent.tools],
lastEntryId: 0,
});
}
await expectVariant(feed, "reporter").report({ children }, ctx);
await reporter.push(ctx);
await signal.next(seen, aborted);
} catch (error) {
if (aborted.aborted) return;
@ -176,19 +210,21 @@ export default defineFacet((host, self) => {
if (!canSend(principal, session)) throw forbidden("participants only");
const harness = harnessOf(self);
const worker = workerOf(self);
const reports = self.registry(ChildReports);
return {
variant: "participant",
members: {
archive: ({ conversation, archived }, ctx) =>
worker().liveness.call(async () => {
const durable = harness().durable;
const context = durableContext(ctx);
const registry = await durable.snapshot(Registry, ROOT_CONVERSATION_ID, context);
const registry = await durable.snapshot(Registry, ROOT_CONVERSATION_ID, ctx);
if (Object.values(registry?.oneShots ?? {}).some((agent) => agent.conversationId === conversation))
throw forbidden(READ_ONLY);
if (!Object.values(registry?.agents ?? {}).some((agent) => agent.conversationId === conversation))
throw new Error("Unknown conversation");
await setArchived(durable, ROOT_CONVERSATION_ID, conversation as ConversationId, archived, context);
await setArchived(durable, ROOT_CONVERSATION_ID, conversation as ConversationId, archived, ctx);
// The sidebar's archived flag is the server's: pushed before the answer.
await reports.value?.push(ctx);
return null;
}),
},

View file

@ -12,7 +12,7 @@ import {
until,
type WorkerPrincipal,
} from "@p2/app/v1";
import { Hosts, Server, serverOf, Upgrades, type WorkerState } from "@p2/app/v1/server";
import { Hosts, Server, Upgrades, type WorkerState } from "@p2/app/v1/server";
import { Catalog, SessionArchived } from "../sessions/contract.ts";
import { ChildStore, conversationLocation } from "../subagents/contract.ts";
import { AgentTerminals, TerminalServiceObject, TerminalServices, Terminals } from "./contract.ts";
@ -69,10 +69,10 @@ export default defineFacet((host) => {
const service = () => services().service;
const catalog = self.get(Catalog, {});
const children = self.get(ChildStore, {});
const server = serverOf(self);
const viewResource = self.open(
async () => {
await server().root((ctx) => catalog.get({ id }, ctx));
async (context) => {
// An unknown session refuses the view.
await catalog.get({ id }, context);
return service().open(id);
},
(view) => service().release(view),

View file

@ -21,7 +21,7 @@ export default defineFacet((host) => {
const via = <T>(
call: (server: Awaited<ReturnType<typeof terminals>>, ctx: Ctx) => Promise<T>,
context: Context,
): Promise<T> => worker().root(async (ctx) => call(await terminals(ctx), ctx), context.abortSignal);
): Promise<T> => worker().root(async (ctx) => call(await terminals(ctx), ctx), context);
const backend: TerminalBackend = {
list: async (context) => (await via((server, ctx) => server.list({}, ctx), context)).windows,
open: (args, context) => via((server, ctx) => server.open(args, ctx), context),

View file

@ -1,6 +1,7 @@
// `/tree` in the session worker (`docs/tree.md`): the tree of the top-level conversations' messages, switching the
// current main conversation (forking a new branch where none ends at the chosen point), and the rules that come with
// it: older branches are read-only, branches carry stored labels, and the session catalog follows `p2.main`.
// it: older branches are read-only, branches carry stored labels, and the session catalog follows `p2.main`, pushed by
// the worker inside the method that changed it.
import type { AssistantMessage } from "@earendil-works/pi-ai";
import {
AgentDoc,
@ -25,11 +26,8 @@ import {
isOwner,
messageData,
peerMessage,
signalOf,
} from "@p2/app/v1";
import { durableContext } from "@p2/app/v1/node";
import {
CommitSignal,
harnessOf,
isTopLevel,
MainDoc,
@ -67,42 +65,19 @@ export default defineFacet((host) => {
self.contribute(ReadOnlyConversations, {
tree: async (conversation, ctx) => {
const durable = harness().durable;
const context = durableContext(ctx);
const id = conversation as ConversationId;
return id !== (await mainConversation(durable, context)) && (await isTopLevel(durable, id, context))
return id !== (await mainConversation(durable, ctx)) && (await isTopLevel(durable, id, ctx))
? NOT_CURRENT
: undefined;
},
});
// The catalog follows `p2.main` (the main conversation, the branches and their labels): at start (also after a
// worker stopped between a change and its report) and at every change.
const commits = self.open(
async () =>
new CommitSignal(harness().durable, (publication) =>
publication.changes.some(
(change) => change.type === "document" && change.record.kind === MainDoc.definition.kind,
),
),
(signal) => signal.close(),
);
// The catalog follows `p2.main` (the main conversation, the branches and their labels): the methods changing it
// push it before they answer (`reportTree`); at start the worker pushes it once, which also covers a worker that
// stopped between a change and its push. Nothing else changes `p2.main`.
self.onLive(async (ctx) => {
const signal = signalOf(ctx);
try {
await fillLabels(harness().durable, durableContext(ctx));
} catch (error) {
if (signal.aborted) return;
console.error("p2 tree: labelling the branches failed:", error);
}
while (!signal.aborted) {
const seen = commits.value.version;
try {
await reportTree(harness().durable, ctx);
} catch (error) {
if (signal.aborted) return;
console.error("p2 tree: reporting the branches failed:", error);
}
await commits.value.next(seen, signal).catch(() => {});
}
const durable = harness().durable;
await fillLabels(durable, ctx);
await reportTree(durable, ctx);
});
});
@ -115,25 +90,25 @@ export default defineFacet((host) => {
return {
variant: "owner",
members: {
tree: (ctx) => worker().liveness.call(() => readTree(harness().durable, durableContext(ctx))),
tree: (ctx) => worker().liveness.call(() => readTree(harness().durable, ctx)),
navigate: ({ entry }, ctx) =>
worker().liveness.call(async () => {
const durable = harness().durable;
const switched = await navigate(durable, entry as EntryId, durableContext(ctx));
const switched = await navigate(durable, entry as EntryId, ctx);
await reportTree(durable, ctx);
return switched;
}),
select: ({ conversation }, ctx) =>
worker().liveness.call(async () => {
const durable = harness().durable;
await select(durable, conversation as ConversationId, durableContext(ctx));
await select(durable, conversation as ConversationId, ctx);
await reportTree(durable, ctx);
return null;
}),
rename: ({ conversation, label }, ctx) =>
worker().liveness.call(async () => {
const durable = harness().durable;
await rename(durable, conversation, label, durableContext(ctx));
await rename(durable, conversation, label, ctx);
await reportTree(durable, ctx);
return null;
}),
@ -146,7 +121,7 @@ export default defineFacet((host) => {
/** Tells the catalog the current main conversation and the branches, newest first (it ignores an unchanged report). */
async function reportTree(durable: Harness, ctx: Ctx): Promise<void> {
const state = await durable.snapshot(MainDoc, durableContext(ctx));
const state = await durable.snapshot(MainDoc, ctx);
const main = state?.conversation ?? ROOT_CONVERSATION_ID;
const branches: Branch[] = (state?.branches ?? [{ conversation: ROOT_CONVERSATION_ID, label: null }]).toReversed();
await expectVariant(await ctx.get(Sessions, {}), "worker").treeSelf({ main, branches }, ctx);

View file

@ -18,6 +18,7 @@ import { join } from "node:path";
import type { Duplex } from "node:stream";
import { compile, nul, obj, type Static, str, union } from "@earendil-works/pi-schema";
import {
BACKGROUND_CONTEXT,
type Ctx,
canSend,
contribute,
@ -265,8 +266,9 @@ export default defineFacet((host) => {
open.set(key, count + 1);
const recheck = isGuest(principal)
? setInterval(() => {
// A timer of the socket, nothing at hand: the background context.
void natives()
.root((ctx) => mayTalk(principal, ctx, session))
.root((ctx) => mayTalk(principal, ctx, session), BACKGROUND_CONTEXT)
.catch(() => false)
.then((allowed) => {
if (!allowed) browser.close(1008, "Access ended");

View file

@ -6,14 +6,17 @@
// A read/write guest talks (its transcripts are its own messages) but hears nothing yet: the answers are read aloud only
// in the owner's browser. View-only guests have no voice mode. Only when the server has a speech service (voice.json).
import { Access, BACKGROUND_CONTEXT, contribute, defineFacet, grantMode, type HeldIn, liveIn } from "@p2/app/v1";
import { BACKGROUND_CONTEXT, contribute, defineFacet, type HeldIn, liveIn } from "@p2/app/v1";
import {
CheckIcon,
closeLayers,
openLayer,
Sheet,
Slots,
useCanWrite,
useComposerState,
useObject,
useOptimisticCall,
useOwner,
useReadable,
useRoute,
@ -172,11 +175,7 @@ function MicIcon({ muted = false, size = 18 }: { muted?: boolean; size?: number
* view-only guest only reads).
*/
function useTalker(): { owner: boolean; talker: boolean } {
const owner = useOwner();
const { session } = useRoute();
const access = useObject(Access, {});
const grant = useReadable(liveIn(access, "guest")?.grant);
return { owner, talker: owner || grantMode(grant, session) === "full" };
return { owner: useOwner(), talker: useCanWrite(useRoute().session) };
}
/** The voice mode switch of a conversation (the owner's variant or a guest's) and its shared voice. */
@ -379,7 +378,12 @@ function VoiceSheet({ config, owner, onClose }: { config: VoiceConfig; owner: bo
const [, setTick] = useState(0);
useEffect(() => shown.subscribe(() => setTick((tick) => tick + 1)), []);
const mine = useStored();
const settings = shared ?? mine;
// The owner's choice shows at once; a refused one goes back with the error in the toast.
const [settings, choose] = useOptimisticCall(
shared ?? mine,
(_, next: VoiceSettings) => next,
useComposerState().fail,
);
const { engine: ttsEngine, voice, language } = resolveVoice(config, settings);
const languages = languagesOf(ttsEngine);
const voiceName = ttsEngine?.voices.find((option) => option.id === voice)?.name ?? voice;
@ -416,7 +420,7 @@ function VoiceSheet({ config, owner, onClose }: { config: VoiceConfig; owner: bo
const next = resolved(config, { ...settings, ...change });
sampling.current = next;
stored.set(next);
void ownerMode?.choose(next, BACKGROUND_CONTEXT).catch((error: unknown) => console.error("[voice]", error));
if (ownerMode) choose(next, () => ownerMode.choose(next, BACKGROUND_CONTEXT));
};
const sample = () => sampler.sample();

View file

@ -5,24 +5,22 @@
// owner's switch or any guest's is, since that guest talks by voice too.
import { type ConversationId, defineDoc, defineExtension, section } from "@earendil-works/pi-durable";
import { bool, nul, obj, type Static, union } from "@earendil-works/pi-schema";
import { bool, obj, type Static } from "@earendil-works/pi-schema";
import {
type Context,
type Ctx,
canSend,
contribute,
defineFacet,
defineTypedState,
defineState,
deriveTyped,
forbidden,
invalid,
isOwner,
principalKey,
signalOf,
} from "@p2/app/v1";
import { durableContext } from "@p2/app/v1/node";
import { agentConversation, CommitSignal, Extensions, harnessOf } from "@p2/app/v1/worker";
import { VoiceChoiceSchema, VoiceMode } from "./contract.ts";
import { agentConversation, Extensions, harnessOf } from "@p2/app/v1/worker";
import { type VoiceChoiceSchema, VoiceMode } from "./contract.ts";
export type VoiceChoice = Static<typeof VoiceChoiceSchema>;
@ -97,11 +95,11 @@ export const voiceExtension = defineExtension({
],
});
/** What the cells show: the document's voice, and the switch (the document's for the owner, a guest's own). */
const Shown = defineTypedState("p2.voice-mode", {
schema: obj({ voice: union([VoiceChoiceSchema, nul()]), enabled: bool() }),
version: 2,
initial: () => ({ voice: null, enabled: false }),
/** A read/write guest's own microphone switch (the owner's is the document's). */
const GuestSwitch = defineState("p2.voice-mode.guest", {
schema: obj({ enabled: bool() }),
version: 1,
initial: () => ({ enabled: false }),
});
export default defineFacet((host) => {
@ -115,57 +113,34 @@ export default defineFacet((host) => {
if (!canSend(principal, session)) throw forbidden("participants only");
const id = conversation as ConversationId;
const harness = harnessOf(self);
/** Where the agent's voice mode is (the main agent's on conversation 1); set when the object opens. */
/** Where the agent's voice 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(VoiceModeDoc, holder, ctx)) ?? {
enabled: false,
voice: null,
};
const owner = isOwner(principal);
const shown = self.state(Shown);
const show = (doc: { enabled: boolean; voice: VoiceChoice | null }, ctx: Context) =>
shown.change((draft) => {
draft.voice = doc.voice;
if (owner) draft.enabled = doc.enabled;
}, 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(await read(ctx), ctx);
return new CommitSignal(durable, (publication) =>
publication.changes.some(
(change) =>
change.type === "document" &&
change.record.kind === VoiceModeDoc.definition.kind &&
change.conversationId === holder,
),
);
},
(signal) => signal.close(),
);
// The document is the truth; the cells show it. Subscribed before reading, so no commit is missed.
self.onLive(async (ctx) => {
const signal = signalOf(ctx);
while (!signal.aborted) {
const seen = commits.value.version;
await show(await read(ctx), ctx);
await commits.value.next(seen, signal).catch(() => {});
}
// The document is the truth, followed: a commit below reaches it before the method returns.
const doc = self.follow(async (observer, ctx) => {
const durable = harness().durable;
if (!(await durable.conversation(id, durableContext(ctx))))
throw new Error(`Unknown conversation ${conversation}`);
holder = await agentConversation(durable, id, durableContext(ctx));
const observed = await durable.observeDoc(VoiceModeDoc, holder, observer, ctx);
if (observed !== undefined) return observed;
// Nobody switched it yet: an absent address cannot be followed, so it is created as it reads (off, no voice).
await durable.commit(async (tx) => {
await tx.doc(VoiceModeDoc, holder);
}, durableContext(ctx));
return durable.observeDoc(VoiceModeDoc, holder, observer, ctx);
});
const owner = isOwner(principal);
const commit = async (ctx: Ctx, change: (doc: { enabled: boolean; voice: VoiceChoice | null }) => void) => {
const durable = await harness().durable.conversation(id, durableContext(ctx));
if (!durable) throw invalid("args", `Unknown conversation ${conversation}`);
await durable.commit(async (tx) => change(await tx.doc(VoiceModeDoc, holder)), durableContext(ctx));
return null;
};
const enabled = self.derive([shown], ([value]) => value.enabled);
const voice = deriveTyped(self, [shown], ([value]) => value.voice);
const voice = deriveTyped(self, [doc], ([value]) => value.voice);
if (!owner) {
// A read/write guest: its own microphone switch, counted while this object lives.
const key = principalKey(principal);
const guestSwitch = self.state(GuestSwitch);
self.open(
async () => key,
() => setGuestVoice(String(id), key, false),
@ -173,13 +148,13 @@ export default defineFacet((host) => {
return {
variant: "participant",
members: {
enabled,
enabled: self.derive([guestSwitch], ([value]) => value.enabled),
voice,
set: async ({ enabled: on }, ctx) => {
if (on && !(await harness().durable.conversation(id, durableContext(ctx))))
throw invalid("args", `Unknown conversation ${conversation}`);
setGuestVoice(String(id), key, on);
await shown.change((draft) => {
await guestSwitch.change((draft) => {
draft.enabled = on;
}, ctx);
return null;
@ -190,7 +165,7 @@ export default defineFacet((host) => {
return {
variant: "owner",
members: {
enabled,
enabled: self.derive([doc], ([value]) => value.enabled),
voice,
set: ({ enabled: on }, ctx) =>
commit(ctx, (doc) => {

View file

@ -1,6 +1,14 @@
import { randomUUID } from "node:crypto";
import { once } from "node:events";
import { canSend, contribute, defineFacet, filePreview, signalOf } from "@p2/app/v1";
import {
canSend,
contribute,
defineFacet,
filePreview,
signalOf,
withAbortSignal,
withoutAbortSignal,
} from "@p2/app/v1";
import { fromOrigin, type RouteHandler, Routes, sendJson, serverOf } from "@p2/app/v1/server";
import { Catalog } from "../sessions/contract.ts";
import { WorkspaceTransfer } from "./contract.ts";
@ -71,11 +79,13 @@ export default defineFacet((host) => {
} catch (error) {
// Cleanup has its own uncancelled root after a disconnect.
await server()
.root(async (cleanup) =>
(await cleanup.get(WorkspaceTransfer, { session, conversation })).write(
{ path, name, token, offset, data: "", final: false, abort: true },
cleanup,
),
.root(
async (cleanup) =>
(await cleanup.get(WorkspaceTransfer, { session, conversation })).write(
{ path, name, token, offset, data: "", final: false, abort: true },
cleanup,
),
withoutAbortSignal(outer),
)
.catch(() => {});
throw error;
@ -151,7 +161,7 @@ export default defineFacet((host) => {
}
response.end();
},
AbortSignal.any([abort.signal, signalOf(outer)]),
withAbortSignal(abort.signal, outer),
);
} catch (error) {
request.resume();

View file

@ -4,7 +4,7 @@
// filesystem watch, which read-only guests cannot get. Comments, commits and uploads stay on the web for now.
import type { Held } from "@p2/app/v1";
import { BACKGROUND_CONTEXT, contribute, defineFacet, statusOf, whenLive } from "@p2/app/v1";
import { BACKGROUND_CONTEXT, contribute, defineFacet, liveIn, statusOf, whenLive } from "@p2/app/v1";
import type { TuiContext } from "@p2/app/v1/tui";
import {
ListOverlay,
@ -334,17 +334,17 @@ export class GitBranch implements TuiSurface {
if (!allowed || !session) return;
const scope = new TuiScope(this.runtime);
this.scope = scope;
const workspace = scope.watch(Workspace, { session, conversation: this.runtime.conversation }, (_ref, follow) =>
follow(_ref.changes, () => {
scope.watch(Workspace, { session, conversation: this.runtime.conversation }, (workspace, follow) =>
follow(workspace.changes, () => {
// The first answer at once; file changes coalesce.
clearTimeout(this.timer);
this.timer = setTimeout(() => this.ask(workspace, key), this.branch === undefined ? 0 : 500);
}),
);
}
private ask(workspace: () => Held<typeof Workspace> | undefined, key: string | undefined): void {
const ref = workspace();
if (ref === undefined || statusOf(ref) !== "live" || ref.variant !== "reader" || key !== this.key) return;
private ask(workspace: Held<typeof Workspace>, key: string | undefined): void {
const ref = liveIn(workspace, "reader");
if (ref === undefined || key !== this.key) return;
if (this.asking) {
this.again = true;
return;

View file

@ -1,12 +1,14 @@
import { Access, BACKGROUND_CONTEXT, contribute, defineFacet, follow, grantMode, liveIn } from "@p2/app/v1";
import { BACKGROUND_CONTEXT, contribute, defineFacet, follow, liveIn } from "@p2/app/v1";
import type { Navigation, WorkspaceRoute } from "@p2/app/v1/web";
import {
FolderIcon,
locationLabel,
Slots,
useCanWrite,
useComposerState,
useNavigation,
useObject,
useOwner,
useReadable,
useRoute,
useStatus,
@ -21,15 +23,8 @@ import { WorkspacePanel as MobileWorkspacePanel } from "./Workspace.tsx";
function WorkspaceButton() {
const nav = useNavigation();
const route = useRoute();
const access = useObject(Access, {});
const grant = useReadable(liveIn(access, "guest")?.grant);
const status = useStatus(access);
if (
!route.session ||
status !== "live" ||
(access?.variant !== "owner" && grantMode(grant, route.session) !== "full")
)
return null;
const canWrite = useCanWrite(route.session);
if (!route.session || !canWrite) return null;
return (
<button
type="button"
@ -50,25 +45,18 @@ function WorkspaceButton() {
function WorkspacePanel() {
const route = useRoute(),
nav = useNavigation();
const access = useObject(Access, {});
const grant = useReadable(liveIn(access, "guest")?.grant);
const status = useStatus(access);
const owner = useOwner();
const canWrite = useCanWrite(route.session);
// Retry opens the workspace again: a new panel holds a new reference.
const [attempt, setAttempt] = useState(0);
if (
!route.session ||
!route.workspace ||
status !== "live" ||
(access?.variant !== "owner" && grantMode(grant, route.session) !== "full")
)
return null;
if (!route.session || !route.workspace || !canWrite) return null;
return (
<NativePanel
key={`${route.session}:${route.conversation}:${attempt}`}
onRetry={() => setAttempt((current) => current + 1)}
session={route.session}
conversation={route.conversation}
owner={access?.variant === "owner"}
owner={owner}
nav={nav}
route={route.workspace}
/>
@ -101,7 +89,7 @@ function NativePanel({
);
const cwd = location?.cwd ?? "";
const { steer, setError } = useComposerState();
// Why the workspace is not open: a failed open (the reference is gone, and taken again shortly) shows its error
// Why the workspace is not open: a failed open shows its error (Retry opens it again with a new reference)
// instead of loading forever.
const [unavailable, setUnavailable] = useState<string>();
useEffect(() => {

View file

@ -1,6 +1,15 @@
import { AgentDoc, type ConversationId, ROOT_CONVERSATION_ID } from "@earendil-works/pi-durable";
import type { ExecutionEnv } from "@earendil-works/pi-durable/env";
import { canSend, defineFacet, forbidden, type Location, type Subscribable, sameLocation, signalOf } from "@p2/app/v1";
import {
canSend,
defineFacet,
followNative,
forbidden,
type Location,
type Subscribable,
sameLocation,
signalOf,
} from "@p2/app/v1";
import { durableContext } from "@p2/app/v1/node";
import { conversationEnvironment, harnessOf, isLocalEnvironment, workerOf } from "@p2/app/v1/worker";
import { LocationMoveDoc } from "../locations/contract.worker.ts";
@ -62,15 +71,6 @@ export default defineFacet((host) => {
const harness = harnessOf(self);
const worker = workerOf(self);
const writes = self.get(WorkspaceWrites, {});
const changes = self.writable(0);
self.open(
async () => {
const liveness = worker().liveness;
liveness.add("viewers", 1);
return liveness;
},
(liveness) => liveness.add("viewers", -1),
);
/** The viewed conversation's workspace where it works now, and the watch reporting its changes to `listeners`. */
const openWorkspace = async (context: ReturnType<typeof durableContext>, notify: () => void) => {
const durable = harness().durable;
@ -124,10 +124,13 @@ export default defineFacet((host) => {
},
};
let pending: ReturnType<typeof setTimeout> | undefined;
// Counts the workspace's changes (at most one per 100 ms): viewers read again at each.
let version = 0;
const notify = (): void => {
if (pending) return;
pending = setTimeout(() => {
pending = undefined;
version++;
for (const fn of listeners) fn(null);
}, 100);
};
@ -225,6 +228,9 @@ export default defineFacet((host) => {
return {
events,
current,
get version() {
return version;
},
close: async () => {
stop();
closing.abort();
@ -235,13 +241,12 @@ export default defineFacet((host) => {
},
(value) => value.close(),
);
// Closed before the workspace (reverse order), so no change arrives after the writable ends.
self.open(
async () => workspaceResource.value.events.subscribe(() => changes.set(changes.value + 1)),
(stop) => stop(),
);
return {
changes: self.derive([changes], ([value]) => value),
changes: followNative(
self,
(changed) => workspaceResource.value.events.subscribe(changed),
() => workspaceResource.value.version,
),
query: (query, ctx) =>
worker().liveness.call(async () => {
if ("path" in query && workspacePath(query.path) === undefined)

View file

@ -9,11 +9,15 @@ export {
ChordError,
type Contract,
constant,
defineJob,
defineRegistry,
defineState,
evolve,
type HandleOf,
isChordError,
type JobCtx,
type JobError,
type JobHandle,
type KeyedHandle,
keyed,
keyedEntries,
@ -21,11 +25,14 @@ export {
mapShared,
method,
type Native,
type Outcome,
orderedEntries,
PluginError,
type RefState,
type Registry,
type RegistryValue,
type Run,
type RunningEntry,
remote,
type SingletonHandle,
sleep,
@ -40,6 +47,7 @@ export {
type Context,
withAbortSignal,
withCancel,
withoutAbortSignal,
} from "@earendil-works/chord/context";
export type { JsonValue } from "@earendil-works/chord/json";
export type { NativeWatchable, WatchCall, WatchHandle } from "@earendil-works/chord/watch";

View file

@ -5,9 +5,10 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:ht
import { homedir } from "node:os";
import { join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { isChordError, type PluginFacet, webSocketLink } from "@earendil-works/chord";
import { BACKGROUND_CONTEXT, withAbortSignal } from "@earendil-works/chord/context";
import { isChordError, openStore, type PluginFacet, sqliteBackend, webSocketLink } from "@earendil-works/chord";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import type { RolloutResult } from "@earendil-works/chord/plugins";
import { openNodeSqliteDatabase } from "@earendil-works/chord/sqlite/node";
import { WebSocketServer } from "ws";
import { type Ctx, createHost, type Host, type HostError, hostErrorText, Server } from "../runtime/chord.ts";
import { envDefaultModel } from "../runtime/default-model.ts";
@ -122,10 +123,17 @@ export async function startServer(options: ServerOptions = {}): Promise<P2Server
throw new Error(`Another p2 server uses ${dataDir}`, { cause: error });
});
const allowedHosts = options.allowedHosts ?? parseAllowedHosts(process.env.P2_ALLOWED_HOSTS);
// The server's Chord store: persisted documents and job runs of its host-lifetime objects (session lifecycle
// operations, search rebuilds). p2's older SQLite files stay their owners' natives.
const store = await openStore(
sqliteBackend(await openNodeSqliteDatabase(join(dataDir, "server.sqlite"))),
BACKGROUND_CONTEXT,
);
// The server's own requests act as `SERVER`: a worker tells them from a person's (`WorkerBoundary`).
const host = createHost({
address: Server(),
principal: SERVER,
store,
onError: (error) => {
if (isOwnerNotice(error)) processLogs.notice("server", hostErrorText(error));
console.error("p2 server:", error);
@ -306,8 +314,7 @@ export async function startServer(options: ServerOptions = {}): Promise<P2Server
facet: serverCore(host, info, {
resourceOptions: options.resources ?? {},
workers,
root: (fn, signal) =>
host.root(fn, signal ? withAbortSignal(signal, BACKGROUND_CONTEXT) : BACKGROUND_CONTEXT),
root: (fn, context) => host.root(fn, context),
}),
},
{ name: "@p2/tui-preferences", facet: tuiPreferencesFacet(dataDir) },
@ -321,6 +328,7 @@ export async function startServer(options: ServerOptions = {}): Promise<P2Server
processLogs.notice("server", `Server startup failed: ${error instanceof Error ? error.message : String(error)}`);
await workers.close(BACKGROUND_CONTEXT).catch(() => {});
await host.close(BACKGROUND_CONTEXT).catch(() => {});
await store.close(BACKGROUND_CONTEXT).catch(() => {});
p2Plugins.close();
stopLogs();
await new Promise<void>((resolve) => http.close(() => resolve()));
@ -694,6 +702,7 @@ export async function startServer(options: ServerOptions = {}): Promise<P2Server
await new Promise<void>((resolve) => sockets.close(() => resolve()));
await workers.close(BACKGROUND_CONTEXT);
await host.close(BACKGROUND_CONTEXT);
await store.close(BACKGROUND_CONTEXT);
p2Plugins.close();
stopLogs();
http.closeAllConnections();

View file

@ -3,6 +3,7 @@
// data), logs and owner notices, restarts after a crash (as v1), and the lifecycle steps of relocation and cloning,
// serialized per session.
import { realpathSync } from "node:fs";
import type { FollowAttachment, FollowObserver } from "@earendil-works/chord";
import { BACKGROUND_CONTEXT, type Context } from "@earendil-works/chord/context";
import type { JsonValue } from "@earendil-works/chord/json";
import type { Plugins } from "@earendil-works/chord/plugins";
@ -10,7 +11,13 @@ import { createWorkers, type WorkerState, type Workers, type WorkersError } from
import { type Host, Session } from "../runtime/chord.ts";
import { logWriter, processLogs } from "../runtime/logs.ts";
import { canView, type P2Principal } from "../runtime/principals.ts";
import type { HostEvent, RelocationSteps, SessionWorkerControl } from "../runtime/server.ts";
import type {
HostEvent,
RelocationSteps,
SessionWorkerControl,
WorkerState as SessionWorkerState,
} from "../runtime/server.ts";
import { WorkerBoundary } from "../runtime/session-worker.ts";
import type { WorkerInit } from "../runtime/worker.ts";
export type SessionWorkersOptions = {
@ -43,6 +50,9 @@ export class SessionWorkers implements SessionWorkerControl {
readonly #generations = new Map<string, number>();
readonly #operations = new Map<string, Promise<unknown>>();
readonly #listeners = new Set<(event: HostEvent) => void>();
/** The followers of every worker's state (`observe`), and the state they were given last. */
readonly #observers = new Set<FollowObserver<unknown>>();
#workerStates: Readonly<Record<string, SessionWorkerState>> = {};
readonly #writers = new Map<string, ReturnType<typeof logWriter>>();
/** The redacted reason of each session's last failed start or crash (Chord's public error is a fixed message). */
readonly #reasons = new Map<string, string>();
@ -69,7 +79,7 @@ export class SessionWorkers implements SessionWorkerControl {
static async create(options: SessionWorkersOptions, context: Context): Promise<SessionWorkers> {
const made = new SessionWorkers(options);
made.#workers = await made.#createWorkers(context);
void made.#follow();
await made.#follow();
return made;
}
@ -140,6 +150,11 @@ export class SessionWorkers implements SessionWorkerControl {
return () => this.#listeners.delete(listener);
}
observe(observer: FollowObserver<unknown>): FollowAttachment<Readonly<Record<string, SessionWorkerState>>> {
this.#observers.add(observer);
return { value: this.#workerStates, detach: () => this.#observers.delete(observer) };
}
async relocate(session: string, steps: RelocationSteps): Promise<void> {
const source = `${SESSION_PREFIX}${session}`;
const message = (error: unknown) => (error instanceof Error ? error.message : String(error));
@ -232,7 +247,19 @@ export class SessionWorkers implements SessionWorkerControl {
});
}
async retry(session: string): Promise<void> {
async retry(session: string, context: Context): Promise<SessionWorkerState> {
await this.resume(session);
// A hold of the server's own starts the worker when nothing else holds it; its outcome is the answer.
const boundary = this.#options.host.hold(WorkerBoundary, { session }, context);
try {
await this.#settled(session, context);
} finally {
boundary.release();
}
return this.#workerStates[session] ?? { running: false, generation: this.generation(session) };
}
async resume(session: string): Promise<void> {
this.#crashRestarts.delete(session);
await this.#serially(session, async () => {
await this.#suspended(session, async () => {
@ -244,6 +271,27 @@ export class SessionWorkers implements SessionWorkerControl {
});
}
/** Resolves once the session's worker runs or failed (not while it starts or is stopped); `context` ends the wait. */
async #settled(session: string, context: Context): Promise<void> {
const settled = () => {
const status = this.#status(session);
return status === "running" || status === "failed";
};
if (settled()) return;
const watch = await this.workers.state.watch(context);
try {
await new Promise<void>((resolve, reject) => {
context.abortSignal?.addEventListener("abort", () => reject(context.abortSignal?.reason), { once: true });
if (settled()) return resolve();
watch.start(async () => {
if (settled()) resolve();
});
});
} finally {
await watch.stop();
}
}
/** Stops the session's worker and clears its failure: the next hold (one waiting now too) starts it again. */
async stop(session: string): Promise<void> {
await this.#serially(session, () =>
@ -434,6 +482,7 @@ export class SessionWorkers implements SessionWorkerControl {
async #follow(): Promise<void> {
const watch = await this.workers.state.watch(BACKGROUND_CONTEXT);
const publish = (states: typeof watch.value): void => {
const changed: Record<string, SessionWorkerState> = {};
for (const key of new Set([...Object.keys(this.#states), ...Object.keys(states)])) {
const state = states[key];
if (JSON.stringify(state) === JSON.stringify(this.#states[key])) continue;
@ -451,8 +500,15 @@ export class SessionWorkers implements SessionWorkerControl {
}
: { session, running: state.status === "running", generation, pid: state.pid };
for (const listener of [...this.#listeners]) listener(event);
const { session: _session, ...worker } = event;
changed[session] = worker;
}
this.#states = states;
if (Object.keys(changed).length === 0) return;
this.#workerStates = { ...this.#workerStates, ...changed };
const value = this.#workerStates;
for (const observer of [...this.#observers])
observer.advance({ kind: "value", value, ops: [["r", value]], retired: false }, BACKGROUND_CONTEXT);
};
publish(watch.value);
watch.start(async (call) => {

View file

@ -67,6 +67,8 @@ export async function startTuiClient(options: {
actions?: Readonly<Record<string, LauncherAction>>;
/** The visitor's mouse policy (launcher-only preference): which mouse modes worker output may enable. */
mouse?: MousePolicy;
/** Who the launcher was admitted as: the TUI starts with that role, so owner views hold before `Access` answers. */
role?: "owner" | "guest";
url: URL;
/** The visitor's validated local preferences (section 4.2) and their change feed; sent to every worker as data. */
preferences?: {
@ -409,6 +411,7 @@ export async function startTuiClient(options: {
origin: options.url.origin,
// The visitor's mouse policy decides whether pi-tui captures the mouse (wheel, app-owned selection).
mouse: (options.mouse ?? "off") !== "off",
...(options.role ? { role: options.role } : {}),
},
onFailure: (message) => {
if (sandbox === candidate) fail(message);

View file

@ -21,7 +21,7 @@ import { restoreRuntime, snapshotRuntime } from "../tui/snapshot.ts";
import { parseTerminalState } from "../tui/theme/theme.ts";
type Bridge = {
input: { columns: number; rows: number; origin?: unknown; mouse?: unknown };
input: { columns: number; rows: number; origin?: unknown; mouse?: unknown; role?: unknown };
send(value: unknown): void;
onMessage(handler: (message: Record<string, unknown>) => unknown): void;
};
@ -161,6 +161,8 @@ export async function setupTui(bridge: Bridge, facets: readonly PluginFacet<P2Pr
host.connect(link, linkOptions, BACKGROUND_CONTEXT);
const loaded = await host.load([...facets, { name: "@p2/tui-core", facet: tuiCoreFacet }], BACKGROUND_CONTEXT);
runtime.origin = typeof bridge.input.origin === "string" ? bridge.input.origin.slice(0, 200) : undefined;
// The launcher's admission (a hint from outside the sandbox): owner views hold at once; `Access` confirms it.
if (bridge.input.role === "owner" || bridge.input.role === "guest") runtime.role = bridge.input.role;
await runtime.start();
// A facet left out at load (its setup threw) is shown, not silently missing.
for (const { plugin, error } of loaded.failed)
@ -210,7 +212,27 @@ export async function setupTui(bridge: Bridge, facets: readonly PluginFacet<P2Pr
reported = { target, suspend, busy };
};
runtime.listeners.add(report);
let access: ReturnType<typeof host.hold<typeof Access>> | undefined;
// Who this TUI is: held once (Chord holds it again on every new link), the role and a guest's grant follow it.
const access = host.hold(Access, {}, BACKGROUND_CONTEXT);
follow(access.state, (state) => {
if (state.kind !== "live") return;
const role = access.variant === "owner" ? "owner" : "guest";
if (role === runtime.role) return;
runtime.role = role;
runtime.changed();
});
void whenLive(access).then(
() => {
if (access.variant === "guest")
follow(access.grant, (grant) => {
runtime.grant = grant
? { name: grant.name, sessions: grant.sessions ?? { [grant.session]: grant.mode } }
: undefined;
if (grant) runtime.navigate("chat", grant.session);
});
},
(error: unknown) => runtime.fail(error),
);
bridge.onMessage(async (message) => {
if (message.kind === "snapshot") return snapshotRuntime(runtime);
if (message.kind === "activate") {
@ -228,23 +250,6 @@ export async function setupTui(bridge: Bridge, facets: readonly PluginFacet<P2Pr
host.connect(link, linkOptions, BACKGROUND_CONTEXT);
link.activate();
}
// Held for the connection: the role and a guest's grant follow it (released when the link drops).
access?.release();
const held = host.hold(Access, {}, BACKGROUND_CONTEXT);
access = held;
void whenLive(held)
.then(() => {
runtime.role = held.variant === "owner" ? "owner" : "guest";
runtime.changed();
if (held.variant === "guest")
follow(held.grant, (grant) => {
runtime.grant = grant
? { name: grant.name, sessions: grant.sessions ?? { [grant.session]: grant.mode } }
: undefined;
if (grant) runtime.navigate("chat", grant.session);
});
})
.catch((error: unknown) => runtime.fail(error));
void host
.root(async (ctx) => (await ctx.get(TuiPreferences, {})).read(ctx), BACKGROUND_CONTEXT)
// Server defaults sit below the guest's local preferences (section 4.2).

View file

@ -66,6 +66,7 @@ try {
url: options.url,
credentials: { cookie: admission.cookie },
mouse: local.mouse,
role: admission.role,
actions: launcherActions({
url: options.url,
credentials: { cookie: admission.cookie },

View file

@ -3,7 +3,7 @@
// facet, which owns the session's Durable Harness.
import { isChordError } from "@earendil-works/chord";
import { BACKGROUND_CONTEXT, withAbortSignal } from "@earendil-works/chord/context";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { workerMain } from "@earendil-works/chord/workers/child";
import { registerLazyBedrockProvider } from "@earendil-works/pi-ai/lazy-bedrock-provider";
import { registerLazyOAuthFlows } from "@earendil-works/pi-ai/lazy-oauth";
@ -62,9 +62,7 @@ workerMain(
core: [
{
name: "@p2/core",
facet: workerCore(init, liveness, location, (fn, signal) =>
host.root(fn, signal ? withAbortSignal(signal, context) : context),
),
facet: workerCore(init, liveness, location, (fn, rootContext) => host.root(fn, rootContext)),
},
],
},

View file

@ -72,12 +72,9 @@ export const sessionHost = ({ session }: { readonly session: string }) => Sessio
/** Params fields (Chord's typed schemas): a required string, integer, boolean or one of some strings. */
export const param = { str, int, bool, oneOf };
/** An `invalid` ChordError with `reason` (`args`, `params`, …): a request p2 refuses as malformed. */
export const invalid = (reason: string, message: string): ChordError =>
new ChordError({ code: "invalid", reason, message } as never);
/** A `forbidden` ChordError (p2's access checks in factories, methods and routes). */
export const forbidden = (message: string): ChordError => new ChordError({ code: "forbidden", message });
// Chord's decided errors for plugins: `invalid(reason, message)` for refused arguments or params, `forbidden(message)`
// for a refused principal.
export { forbidden, invalid } from "@earendil-works/chord";
type Variants<R> = R extends { readonly variant: infer V } ? Exclude<V, undefined> : never;
/** The variants of a reference to `C` (remote types), or `never`. */
@ -230,3 +227,25 @@ export function serveKeyed<V>(host: FacetHost, registry: KeyedRegistry<V>): void
new Map([...keyedEntries(table, (value) => value, report)].map(([key, item]) => [key, item.value])),
});
}
/**
* A state source following a native that tells listeners of its changes (`subscribe` returns the stop): `self.follow`
* of a synchronous observer, so the state is the native's, never a copy a routine keeps. Its value is `read()` at
* activation and after every change, sent whole (`[["r", value]]`: derives over it diff for the wire). Both run once
* the object's natives opened, so they may read a native's `.value`. A change the native makes inside a method
* reaches the source before the method's answer (an off-batch change commits on the next microtask).
*/
export function followNative<T>(
self: Self,
subscribe: (changed: () => void) => () => void,
read: () => T,
): Watchable<T> {
return self.follow(async (observer, context) => {
const detach = subscribe(() => {
const value = read();
// JSON at runtime: `read` gives the native's plain data (optional fields TypeScript does not see as JSON).
observer.advance({ kind: "value", value, ops: [["r", value as never]], retired: false }, context);
});
return { value: read(), detach };
});
}

View file

@ -126,7 +126,7 @@ export function errorOf(ref: HeldRef): Error | undefined {
/**
* Releases held references later: a chat stays held for a while after its screen is left, so that switching back is
* instant (v1's client linger), at most `cap` of them at once (the oldest goes first).
* instant, at most `cap` of them at once (the oldest goes first).
*/
export class Lingering {
readonly #delays: Readonly<Record<string, number>>;

Some files were not shown because too many files have changed in this diff Show more