From c9c12d9bdd29dd40ab18f0ec2264363835f5e581 Mon Sep 17 00:00:00 2001 From: Gabrielle Oliveira Date: Tue, 13 Oct 2026 23:53:06 -0300 Subject: [PATCH 1/2] fix(pi): give back refused prompts, and time out stalled uploads A message Leverage refused after Pi stopped waiting for it was dropped, with only a notice. It now goes back into the composer, above whatever was typed since, both for a session's messages and for the first message that creates one. An attachment's upload to storage was the one Leverage call with no timeout, so a stalled one blocked Pi's input. It now gives up after 30 seconds like the others, and the message goes back to the composer. A login file kept with a looser mode was tightened only after the tokens were written to it. It is tightened first. --- src/config.ts | 7 +++++-- src/index.ts | 12 ++++++++++- src/session.ts | 5 ++++- src/workspace/api.ts | 9 +++++++- tests/extension.test.ts | 42 ++++++++++++++++++++++++++++++++++++++ tests/workspace/fixture.ts | 9 ++++++++ 6 files changed, 79 insertions(+), 5 deletions(-) diff --git a/src/config.ts b/src/config.ts index 063b45d..feca5b1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,6 @@ import { chmodSync, + existsSync, mkdirSync, readFileSync, rmSync, @@ -161,9 +162,11 @@ export function saveLogin( ): void { const file = loginFile(env); mkdirSync(dirname(file), { recursive: true, mode: 0o700 }); + // A file kept before keeps its mode, so tighten it before the tokens go in. + if (existsSync(file)) { + chmodSync(file, 0o600); + } writeFileSync(file, `${JSON.stringify(login, null, 2)}\n`, { mode: 0o600 }); - // A file kept before keeps its mode, so set it again. - chmodSync(file, 0o600); } /** Forgets the `/leverage login` sign-in, and says what it was. */ diff --git a/src/index.ts b/src/index.ts index dea0f91..bee5d96 100644 --- a/src/index.ts +++ b/src/index.ts @@ -517,6 +517,15 @@ export default function leverage(pi: ExtensionAPI): void { editor.setText(text); } }; + // A message Leverage refused after Pi stopped waiting for it goes back into + // the composer, above whatever is typed there since. + const giveBack = (ctx: ExtensionContext, text: string, error: unknown) => { + report(ctx, error); + const typed = ctx.ui.getEditorText(); + ctx.ui.setEditorText( + [text, typed].filter((one) => one.trim()).join("\n\n"), + ); + }; // Pi's history of the session: Leverage's older messages, page by page. const history = async (ctx: ExtensionContext) => { const current = requireSession(); @@ -631,6 +640,7 @@ export default function leverage(pi: ExtensionAPI): void { idle: () => !busy() && !creating, opened: () => redrawConversation(), revoked: () => owner.abort(), + refused: (text, error) => giveBack(ctx, text, error), }); void offerModels(session.providerFamily) .then(() => follow(ctx)) @@ -790,7 +800,7 @@ export default function leverage(pi: ExtensionAPI): void { } await createAndSend(prompt, ctx).catch((error: unknown) => { if (!(error instanceof UnconfirmedError) && opening === generation) { - report(ctx, error); + giveBack(ctx, prompt.text, error); } }); } diff --git a/src/session.ts b/src/session.ts index 03e507f..38c1e4f 100644 --- a/src/session.ts +++ b/src/session.ts @@ -73,6 +73,9 @@ export interface ViewHooks { opened(): void; // The server removed access, so the connection must start again. revoked(): void; + // Leverage refused a message Pi had stopped waiting for, so its text is + // given back. + refused(text: string, error: unknown): void; } export const LINK_ENTRY = "leverage-session"; @@ -622,7 +625,7 @@ export class SessionView { } catch (error) { if (!(error instanceof UnconfirmedError) && !this.closed) { this.unconfirmed = undefined; - report(this.ctx, error); + this.hooks.refused(prompt.text, error); } } finally { if (!this.closed) { diff --git a/src/workspace/api.ts b/src/workspace/api.ts index 42d11ce..4e3e4a7 100644 --- a/src/workspace/api.ts +++ b/src/workspace/api.ts @@ -44,6 +44,8 @@ import { // The most a download reads into memory before it is saved. const MAX_DOWNLOAD_BYTES = 256 * 1024 * 1024; +// How long an upload may take. The message it belongs to waits on it. +const UPLOAD_MS = 30_000; const workspaceList = z.array(z.object({ id: z.string(), slug: z.string() })); @@ -423,11 +425,16 @@ export class WorkspaceClient { if (init.mode !== "single" || !init.url) { throw new InputError("This attachment is too large to send from Pi."); } + const timeout = AbortSignal.timeout(UPLOAD_MS); const stored = await fetch(init.url, { method: "PUT", - signal, + signal: signal ? AbortSignal.any([signal, timeout]) : timeout, headers: { "content-type": file.contentType }, body: Buffer.from(file.bytes), + }).catch((error: unknown) => { + throw timeout.aborted + ? new StateError("The attachment upload timed out.") + : error; }); if (!stored.ok) { throw new StateError(`The attachment upload failed (${stored.status}).`); diff --git a/tests/extension.test.ts b/tests/extension.test.ts index 1763054..0d3f7c8 100644 --- a/tests/extension.test.ts +++ b/tests/extension.test.ts @@ -218,6 +218,7 @@ function commandActions( */ function composer(runner: ExtensionRunner) { let factory: Parameters[0]; + let text = ""; const widgets: string[] = []; const notices: string[] = []; const overlays: string[] = []; @@ -230,6 +231,10 @@ function composer(runner: ExtensionRunner) { notify: (message) => { notices.push(message); }, + getEditorText: () => text, + setEditorText: (value) => { + text = value; + }, setWidget: (_key, value) => { if (typeof value === "function") { widgets.push( @@ -284,6 +289,8 @@ function composer(runner: ExtensionRunner) { notices, overlays, latest: () => widgets.at(-1) ?? "", + // What Pi's composer holds when the extension sets it through the UI. + text: () => text, editor: () => factory!({ requestRender() {} } as never, theme, keys), }; } @@ -813,6 +820,41 @@ describe("Pi hosted frontend", () => { expect(transcript(runner)).toContain("Check the deploy"); }, 45_000); + test("a message Leverage refuses after Pi stopped waiting goes back into the composer", async () => { + const f = leverage(); + f.update({ status: "active", turnId: "turn_1" }); + const runner = await load(); + f.configure(runner, SESSION); + const ui = composer(runner); + await runner.emit({ type: "session_start", reason: "startup" }); + await eventually(() => ui.latest().includes("Only you here")); + f.state.dropMessageAck = true; + await runner.emitInput("Check the deploy", undefined, "interactive"); + expect(ui.latest()).toContain("Send not confirmed"); + // Pi sends it again on its own, and Leverage refuses that one. + await f.refuse(2, "Runner unavailable"); + await eventually(() => ui.text() === "Check the deploy"); + expect(ui.notices).toContain("Runner unavailable"); + }, 45_000); + + test("an upload that never answers gives up, and its message goes back into the composer", async () => { + const f = leverage(); + const runner = await load(); + f.configure(runner, SESSION); + const ui = composer(runner); + await runner.emit({ type: "session_start", reason: "startup" }); + await eventually(() => ui.latest().includes("Only you here")); + f.state.stallUploads = true; + await runner.emitInput( + "Explain this image", + [{ type: "image", data: "aGVsbG8=", mimeType: "image/png" }], + "interactive", + ); + expect(ui.notices).toContain("The attachment upload timed out."); + expect(ui.text()).toBe("Explain this image"); + expect(f.sent("session.message")).toHaveLength(0); + }, 45_000); + test("Pi's /compact, /tree, /name and model controls act on the Leverage session", async () => { const f = leverage(); let model: unknown; diff --git a/tests/workspace/fixture.ts b/tests/workspace/fixture.ts index db53123..235a768 100644 --- a/tests/workspace/fixture.ts +++ b/tests/workspace/fixture.ts @@ -153,6 +153,7 @@ export function workspaceFixture(extra?: Extra) { const state = { dropCreated: false, dropMessageAck: false, + stallUploads: false, createCount: 0, refreshCount: 0, version: 0, @@ -245,6 +246,14 @@ export function workspaceFixture(extra?: Extra) { return new Response(null, { status: 400 }); } if (path.startsWith("/upload/")) { + if (state.stallUploads) { + // Storage that never answers, until the client gives up. + return new Promise((resolve) => + request.signal.addEventListener("abort", () => + resolve(new Response(null, { status: 499 })), + ), + ); + } const bytes = (await request.arrayBuffer()).byteLength; const upload = uploads.find((one) => path.endsWith(one.id)); if (upload) { From 244de043ee8e56ccc6988927c145e8252da12669 Mon Sep 17 00:00:00 2001 From: Gabrielle Oliveira Date: Tue, 13 Oct 2026 23:53:06 -0300 Subject: [PATCH 2/2] docs: add the changelog for 0.1.0 --- CHANGELOG.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c2261c2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,39 @@ +# Changelog + +## 0.1.0 - 2026-10-13 + +The first release. Pi runs as a frontend for a Leverage session: the agent +runs in Leverage, and Pi draws the session and sends what you type. Needs Pi +0.99.1 and Node.js 22.19 or newer. + +### Added + +- **Sign-in.** `/leverage login` signs in with a device code and keeps the + login where only you can read it. `/leverage logout` forgets it. Pi also + reads the Leverage CLI's login. +- **Sessions.** `/leverage new` starts one with its first prompt, `/leverage` + opens one your team started, and `/leverage exit` goes back to plain Pi. + Closing Pi doesn't stop the agent. +- **Multiplayer.** Who has the session open, who is typing, and whether the + owner is around. Each message shows who wrote it, and teammates see Pi's + mark on yours. +- **Steering.** Enter while the agent works steers it after its current step. + A follow-up waits its turn, and the inbox shows both. +- **Governance.** The agent asks before it runs a command: approve once, for + the session or always, or deny with a reason. Questions open in Pi's own + dialogs. +- **Sub-agents** work in child conversations that their call names. +- **Session controls.** `/compact`, `/name`, `/tree`, Esc and Pi's model keys + act on the Leverage session. +- **Attachments.** Images you paste are uploaded with your message, and + attached images draw in their cards. +- Pi's local tools stay off and no local model runs while a session is open. + +### Fixed + +- A message Leverage refuses after Pi stopped waiting for it goes back into + the composer, instead of being lost. +- An attachment upload gives up after 30 seconds, instead of blocking Pi's + input until it answers. +- A login file kept with a looser mode is tightened before the tokens are + written to it.