Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 5 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
chmodSync,
existsSync,
mkdirSync,
readFileSync,
rmSync,
Expand Down Expand Up @@ -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. */
Expand Down
12 changes: 11 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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);
}
});
}
Expand Down
5 changes: 4 additions & 1 deletion src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down
9 changes: 8 additions & 1 deletion src/workspace/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() }));

Expand Down Expand Up @@ -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}).`);
Expand Down
42 changes: 42 additions & 0 deletions tests/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ function commandActions(
*/
function composer(runner: ExtensionRunner) {
let factory: Parameters<ExtensionUIContext["setEditorComponent"]>[0];
let text = "";
const widgets: string[] = [];
const notices: string[] = [];
const overlays: string[] = [];
Expand All @@ -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(
Expand Down Expand Up @@ -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),
};
}
Expand Down Expand Up @@ -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;
Expand Down
9 changes: 9 additions & 0 deletions tests/workspace/fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ export function workspaceFixture(extra?: Extra) {
const state = {
dropCreated: false,
dropMessageAck: false,
stallUploads: false,
createCount: 0,
refreshCount: 0,
version: 0,
Expand Down Expand Up @@ -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<Response>((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) {
Expand Down
Loading