mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
1137075c78
commit
20d86c3657
154 changed files with 4996 additions and 3002 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
137
apps/p2/docs/optimistic-ui.md
Normal file
137
apps/p2/docs/optimistic-ui.md
Normal 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).
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) } };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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() })),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ?? [];
|
||||
|
|
|
|||
305
apps/p2/plugins/chat/shell.ts
Normal file
305
apps/p2/plugins/chat/shell.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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() };
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 }[] = [];
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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() })),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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: {} } },
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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: {} };
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = "";
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 }) },
|
||||
|
|
|
|||
|
|
@ -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 } };
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}`),
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue