Runs this Claude Code session as a client of a Leverage session

<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-3f3f46?labelColor=0a0a0a" alt="MIT license"></a> <a href="https://github.com/leverage-computer/claude-plugin/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/leverage-computer/claude-plugin/ci.yml?branch=main&label=CI&labelColor=0a0a0a&color=3f3f46" alt="CI status"></a> <a href=".claude-plugin/plugin.json"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fleverage-computer%2Fclaude-plugin%2Fmain%2F.claude-plugin%2Fplugin.json&query=%24.version&label=Version&labelColor=0a0a0a&color=3f3f46" alt="Plugin version"></a> <a href="#requirements"><img src="https://img.shields.io/badge/Claude%20Code-%E2%89%A52.1.286-3f3f46?labelColor=0a0a0a" alt="Claude Code 2.1.286 or later"></a>
Install · Usage · Features · leverage.computer
claude plugin marketplace add leverage-computer/claude-plugin
claude plugin install leverage@leverage
/leverage login: sign in, and approve in the browser./leverage: open the pane.To start a new session in a space, use the Leverage CLI:
leverage claude <space>
/leverage opens the pane again./leverage help lists the actions you can do with Leverage mod.hooks/register.ts 824 lines1import type { EngineInterface, Register } from "claude-code";
2import { update } from "claude-code";
3
4import type { Room, WorkspaceTree } from "../types";
5import {
6 accessPath,
7 cliConfigPath,
8 connectedBy,
9 connectKept,
10 connectLaunched,
11 connectSaved,
12 drawn,
13 follow,
14 here,
15 isClient,
16 isReachable,
17 LEVERAGE_TOOLS,
18 leverageRequest,
19 MIRRORED,
20 space,
21} from "./client";
22import { registerCommands } from "./commands";
23import { PANE, registerPane } from "./pane";
24import { registerPeople } from "./people";
25
26// Makes this Claude Code session a client of one Leverage session. The turn
27// runs in the Leverage sandbox. This process draws it and never calls a model.
28
29type Block =
30 | { kind: "text"; text: string }
31 | { kind: "thinking"; text: string }
32 | {
33 kind: "tool";
34 id: string;
35 name: string;
36 input: Record<string, unknown>;
37 /** The Leverage subagent the call started. */
38 agent?: {
39 task: string;
40 description: string;
41 prompt: string;
42 type: string | null;
43 };
44 };
45type Waiting = {
46 writable: boolean;
47 approvals: { id: string; tool: string; input: unknown }[];
48 question: string | null;
49};
50type Step =
51 | {
52 blocks: Block[];
53 stop: "tool_use" | "end_turn";
54 cursor: number;
55 turnId: string | null;
56 /** The tokens the Leverage session's context holds. */
57 context: number | null;
58 question: string | null;
59 }
60 | {
61 pending: true;
62 waiting?: Waiting;
63 /** The text the agent is writing, which no row holds yet. */
64 live?: { id: string; kind: "text" | "reasoning"; text: string }[];
65 };
66type Result =
67 | { content: string; isError: boolean }
68 | { pending: true; progress?: string; seen?: number };
69
70// Remote tools with a local twin whose output shape the mod can build.
71const NATIVE = new Set(["Bash", "Read", "Edit", "Write"]);
72const usage = (context: number | null) => ({
73 input_tokens: context ?? 0,
74 output_tokens: 0,
75 cache_creation_input_tokens: 0,
76 cache_read_input_tokens: 0,
77 model: "leverage",
78});
79/** The window's status line: the session's title, or its space. */
80let shown = "Leverage";
81/** The rows already shown as notices, by position. */
82const noticed = new Set<number>();
83/** The background commands still running, by task id. */
84const running = new Set<string>();
85/** The people the last prompt poll saw, as sent, so an unchanged room draws nothing. */
86let seenRoom = "";
87/** Prompts asked to wait, still in Claude Code's queue, oldest first. */
88const waiting: { text: string; skills: { name: string }[]; pasted: boolean }[] =
89 [];
90
91/** The subagents Claude Code is starting for Leverage, until each has its id. */
92const spawning = new Set<Promise<unknown>>();
93
94/** The status line: the session, and what runs in the background. */
95function line(): string {
96 return running.size > 0
97 ? `${shown} · ${running.size} in the background`
98 : shown;
99}
100const CHOICES = ["Once", "This session", "Always", "Reject"];
101const DECISIONS: Record<string, string> = {
102 Once: "once",
103 "This session": "session",
104 Always: "always",
105 Reject: "reject",
106};
107
108async function call<T>(
109 $: EngineInterface,
110 path: string,
111 body?: unknown,
112): Promise<T> {
113 const res = await $.http.fetch(...leverageRequest(path, body));
114 if (!res.ok) {
115 throw new Error(
116 res.status === 401
117 ? connectedBy() === "launched"
118 ? "Leverage refused this Claude Code token. Run leverage claude again."
119 : "Leverage refused this Claude Code token. /leverage login signs in again."
120 : `Leverage answered ${res.status}: ${res.text}`,
121 );
122 }
123 return JSON.parse(res.text) as T;
124}
125
126// The session's title, or its space until the first prompt starts one.
127async function showLabel($: EngineInterface) {
128 let label = "";
129 if (here.id) {
130 label = (await call<{ title: string }>($, `/sessions/${here.id}`)).title;
131 } else if (space()) {
132 const tree = await call<WorkspaceTree>($, "/workspace");
133 const channel = tree.channels.find((one) => one.id === space());
134 label = channel ? `#${channel.name}` : "";
135 }
136 // The engine shows the plugin name before the line.
137 shown = label || "Leverage";
138 $.ui.status(line());
139}
140
141// Asks each call that waits on this person, in Claude Code's own dialog.
142async function askApprovals($: EngineInterface, waiting: Waiting) {
143 for (const pending of waiting.approvals) {
144 if (here.known.has(pending.id)) continue;
145 here.known.add(pending.id);
146 if (!waiting.writable) {
147 $.ui.status(`${shown} · waiting for approval of ${pending.tool}`);
148 continue;
149 }
150 let answer: string;
151 try {
152 answer = await $.ui.ask(
153 `Allow ${pending.tool} to run in Leverage? ${JSON.stringify(pending.input).slice(0, 300)}`,
154 { header: "Approval", options: CHOICES },
155 );
156 } catch {
157 $.ui.toast("Not decided. /leverage approvals asks again.");
158 continue;
159 }
160 // Text typed under Other rejects the call, and Leverage passes it on.
161 const decision = DECISIONS[answer] ?? "reject";
162 await call($, `/approvals/${pending.id}`, {
163 decision,
164 ...(DECISIONS[answer] ? {} : { reason: answer }),
165 });
166 }
167}
168
169// Reads the session's last turns into one notice, so an opened window has context.
170async function showHistory($: EngineInterface) {
171 const { messages } = await call<{
172 messages: { author: string; text: string }[];
173 }>($, `/sessions/${here.id}/history`);
174 if (messages.length === 0) return;
175 const text = messages
176 .map((message) => `${message.author}: ${message.text}`)
177 .join("\n\n");
178 await $.session.append({
179 message: {
180 type: "system",
181 content: [{ type: "text", text: `Earlier in this session:\n\n${text}` }],
182 },
183 });
184}
185
186// Sends a prompt of this window. The first one starts the session, named
187// here so a retried start makes no second one.
188async function send(
189 $: EngineInterface,
190 prompt: {
191 text: string;
192 images: { filename: string; dataUrl: string }[];
193 skills: { name: string }[];
194 steer?: boolean;
195 },
196) {
197 const uuid = crypto.randomUUID();
198 drawn.add(uuid);
199 if (here.id) {
200 await call($, `/sessions/${here.id}/messages`, { ...prompt, uuid });
201 return;
202 }
203 const { steer: _, ...first } = prompt;
204 const started = await call<{ id: string; cursor: number }>($, "/sessions", {
205 id: crypto.randomUUID(),
206 channelId: space(),
207 ...first,
208 uuid,
209 });
210 follow(started.id, started.cursor);
211 await showLabel($);
212}
213
214// Opens the session in this window: its history, and it reads as read.
215async function open($: EngineInterface) {
216 await call($, `/sessions/${here.id}/read`, {});
217 await showHistory($);
218}
219
220function localName(name: string): string {
221 // Claude Code asks it in its own dialog.
222 if (NATIVE.has(name) || name === "AskUserQuestion") return name;
223 const short = name.replace(/^mcp__leverage__/, "");
224 return MIRRORED.includes(short)
225 ? `mcp__leverage__${short}`
226 : "mcp__leverage__remote_tool";
227}
228
229function localInput(name: string, block: Extract<Block, { kind: "tool" }>) {
230 return localName(name) === "mcp__leverage__remote_tool"
231 ? { name: block.name, input: block.input }
232 : block.input;
233}
234
235function lines(text: string): string[] {
236 return text === "" ? [] : text.split("\n");
237}
238
239// The images of the prompt just added to this session, as data URLs.
240async function lastImages($: EngineInterface) {
241 const messages = await $.session.messages({ as: "api" });
242 const prompt = [...messages]
243 .reverse()
244 .find((message) => message.role === "user");
245 return (prompt?.content ?? []).flatMap((block, index) => {
246 const source = block.source as
247 | { type?: string; media_type?: string; data?: string }
248 | undefined;
249 if (block.type !== "image" || source?.type !== "base64") return [];
250 return [
251 {
252 filename: `image-${index + 1}.${source.media_type?.split("/")[1] ?? "png"}`,
253 dataUrl: `data:${source.media_type};base64,${source.data}`,
254 },
255 ];
256 });
257}
258
259// Where an edit's new text starts in its file, read back from the session.
260async function editLine($: EngineInterface, input: Record<string, unknown>) {
261 const path = String(input.file_path ?? "");
262 const after = String(input.new_string ?? "");
263 if (!path.startsWith("/work/") || !after) return 1;
264 try {
265 const file = await call<{ content?: string }>(
266 $,
267 `/sessions/${here.id}/file?path=${encodeURIComponent(path.slice("/work/".length))}`,
268 );
269 const at = file.content?.indexOf(after) ?? -1;
270 return at < 0 ? 1 : (file.content ?? "").slice(0, at).split("\n").length;
271 } catch {
272 return 1;
273 }
274}
275
276/** Above this many line pairs an edit draws as all removed, then all added. */
277const DIFF_LIMIT = 250_000;
278
279// The lines both sides keep show as context; the rest as removed and added.
280function lineDiff(before: string[], after: string[]): string[] {
281 const n = before.length;
282 const m = after.length;
283 if (n * m > DIFF_LIMIT) {
284 return [...before.map((l) => `-${l}`), ...after.map((l) => `+${l}`)];
285 }
286 // kept[i][j]: how many lines the rest of both sides have in common.
287 const kept = Array.from({ length: n + 1 }, () =>
288 new Array<number>(m + 1).fill(0),
289 );
290 for (let i = n - 1; i >= 0; i--) {
291 for (let j = m - 1; j >= 0; j--) {
292 kept[i]![j] =
293 before[i] === after[j]
294 ? kept[i + 1]![j + 1]! + 1
295 : Math.max(kept[i + 1]![j]!, kept[i]![j + 1]!);
296 }
297 }
298 const out: string[] = [];
299 let i = 0;
300 let j = 0;
301 while (i < n || j < m) {
302 if (i < n && j < m && before[i] === after[j]) {
303 out.push(` ${before[i]}`);
304 i++;
305 j++;
306 } else if (i < n && (j === m || kept[i + 1]![j]! >= kept[i]![j + 1]!)) {
307 out.push(`-${before[i]}`);
308 i++;
309 } else {
310 out.push(`+${after[j]}`);
311 j++;
312 }
313 }
314 return out;
315}
316
317// Builds the output shape each native tool's schema demands. `line` is where
318// an edit starts in its file.
319function nativeResult(
320 tool: string,
321 input: Record<string, unknown>,
322 content: string,
323 line = 1,
324) {
325 const path = String(input.file_path ?? "");
326 switch (tool) {
327 case "Bash":
328 return { stdout: content, stderr: "", interrupted: false };
329 case "Read": {
330 const body = content.replace(/^\s*\d+\t/gm, "");
331 const count = lines(body).length;
332 return {
333 type: "text",
334 file: {
335 filePath: path,
336 content: body,
337 numLines: count,
338 startLine: Number(input.offset ?? 1),
339 totalLines: count,
340 },
341 };
342 }
343 case "Edit": {
344 const before = lines(String(input.old_string ?? ""));
345 const after = lines(String(input.new_string ?? ""));
346 return {
347 filePath: path,
348 oldString: String(input.old_string ?? ""),
349 newString: String(input.new_string ?? ""),
350 originalFile: null,
351 structuredPatch: [
352 {
353 oldStart: line,
354 oldLines: before.length,
355 newStart: line,
356 newLines: after.length,
357 lines: lineDiff(before, after),
358 },
359 ],
360 userModified: false,
361 replaceAll: input.replace_all === true,
362 };
363 }
364 case "Write":
365 return {
366 type: "create",
367 filePath: path,
368 content: String(input.content ?? ""),
369 structuredPatch: [],
370 originalFile: null,
371 };
372 }
373 return content;
374}
375
376/** The token `/leverage login` keeps in Claude Code's config; or "{}". */
377async function savedAccess($: EngineInterface): Promise<string> {
378 const file = accessPath(
379 await $.env.get("CLAUDE_CONFIG_DIR"),
380 await $.env.get("HOME"),
381 );
382 return file ? $.fs.read(file).catch(() => "{}") : "{}";
383}
384
385/** The Leverage CLI's config, where it keeps its Claude Code token; or "{}". */
386async function keptConfig($: EngineInterface): Promise<string> {
387 const file = cliConfigPath(
388 await $.env.get("LEVERAGE_CONFIG_DIR"),
389 await $.env.get("XDG_CONFIG_HOME"),
390 await $.env.get("HOME"),
391 );
392 return file ? $.fs.read(file).catch(() => "{}") : "{}";
393}
394
395export const register: Register = (on) => {
396 registerPane(on);
397 registerCommands(on);
398 registerPeople(on);
399
400 on("session.start", async ($, e, next) => {
401 const result = await next(e);
402 await $.command.register({
403 name: "leverage",
404 description: "The Leverage session: pane, approvals, files, model…",
405 argumentHint: "[help]",
406 });
407 const launched = connectLaunched({
408 url: await $.env.get("LEVERAGE_CLAUDE_URL"),
409 token: await $.env.get("LEVERAGE_CLAUDE_TOKEN"),
410 space: await $.env.get("LEVERAGE_CLAUDE_SPACE"),
411 session: await $.env.get("LEVERAGE_CLAUDE_SESSION"),
412 });
413 // Any other window, as Claude Desktop's, takes the token /leverage login
414 // keeps, or the one the Leverage CLI keeps.
415 if (!launched && !connectSaved(await savedAccess($)))
416 connectKept(await keptConfig($));
417 if (launched) {
418 for (const tool of LEVERAGE_TOOLS) await $.tool.register(tool);
419 // A window opened on a session starts at its newest row.
420 if (here.id) {
421 const opened = await call<{ id: string; cursor: number }>(
422 $,
423 `/sessions/${here.id}`,
424 );
425 follow(opened.id, opened.cursor);
426 await open($);
427 }
428 await showLabel($);
429 }
430 if (isReachable()) void $.ui.open({ id: PANE, title: "Leverage" });
431 // Turns started from another client open a turn here, so both show it.
432 // The poll runs from the start, so a later /leverage login needs no restart.
433 $.clock.every(1500, async () => {
434 if (!isClient() || !here.id) return;
435 const { prompts, notices, last, viewers, typing } = await call<
436 {
437 prompts: {
438 uuid: string;
439 author: string;
440 text: string;
441 images: number;
442 steer: boolean;
443 }[];
444 notices: {
445 seq: number;
446 text: string;
447 task?: { id: string; running: boolean };
448 }[];
449 last: number;
450 } & Room
451 >($, `/sessions/${here.id}/prompts?after=${here.polled}`);
452 here.polled = Math.max(here.polled, last);
453 // An older Leverage sends no room.
454 const room: Room = { viewers: viewers ?? [], typing: typing ?? [] };
455 if (JSON.stringify(room) !== seenRoom) {
456 seenRoom = JSON.stringify(room);
457 await update($, { plugin: "leverage", key: "people" }, () => room);
458 }
459 // A row the window does not draw, as a compaction, shows as a notice.
460 for (const notice of notices) {
461 if (noticed.has(notice.seq)) continue;
462 noticed.add(notice.seq);
463 if (notice.task?.running) running.add(notice.task.id);
464 else if (notice.task) running.delete(notice.task.id);
465 $.ui.status(line());
466 await $.session
467 .append({
468 message: {
469 type: "system",
470 content: [{ type: "text", text: notice.text }],
471 },
472 })
473 .catch(() => {});
474 }
475 for (const p of prompts) {
476 if (drawn.has(p.uuid)) continue;
477 drawn.add(p.uuid);
478 // A teammate's steer joins the running turn; Claude Code only shows it.
479 if (p.steer) {
480 await $.session
481 .append({
482 message: {
483 type: "system",
484 content: [
485 {
486 type: "text",
487 text: `${p.author} (in Leverage) adds: ${p.text}`,
488 },
489 ],
490 },
491 })
492 .catch(() => {});
493 continue;
494 }
495 // Claude Code takes no image from a plugin, so the prompt says it has some.
496 const images =
497 p.images > 0
498 ? ` [${p.images} image${p.images > 1 ? "s" : ""} in Leverage]`
499 : "";
500 await $.prompt.submit({
501 text: `${p.author} (in Leverage): ${p.text}${images}`,
502 asUser: true,
503 });
504 }
505 });
506 return result;
507 });
508
509 on("prompt.submit", async ($, e, next) => {
510 if (!isClient()) return next(e);
511 // Only what a person writes goes to Leverage. A plugin's prompt is a
512 // teammate's, already sent, unless it carries a skill.
513 const person = e.origin.kind === "composer" || e.origin.kind === "bridge";
514 // Leverage's turn took a subagent's report already; no turn reads it here.
515 const report =
516 [...here.loops.keys()].some((id) =>
517 e.text.includes(`<agent-message from="${id}">`),
518 ) ||
519 [...here.agents.keys()].some((id) =>
520 e.text.includes(`<tool-use-id>${id}</tool-use-id>`),
521 );
522 if (!person && report) {
523 return { drop: "Leverage's turn has the subagent's report." };
524 }
525 if (!person && !(e.origin.kind === "plugin" && here.skill)) {
526 return next(e);
527 }
528 const skills = here.skill ? [{ name: here.skill }] : [];
529 here.skill = null;
530 // A sent prompt ends the typing the others see.
531 if (here.typedAt && here.id) {
532 here.typedAt = 0;
533 void call($, `/sessions/${here.id}/typing`, { active: false }).catch(
534 () => {},
535 );
536 }
537 const pasted =
538 e.attachments?.some((item) => item.type === "image") ?? false;
539 // Asked to wait (ctrl+x enter), it stays in Claude Code's queue, where it
540 // can be taken back, and goes to Leverage when its own turn starts.
541 if (e.turnId && e.wait) {
542 waiting.push({ text: e.text, skills, pasted });
543 return next(e);
544 }
545 // Typed over a running turn, it steers that turn in Leverage too. Its
546 // images enter the session only later, so a steer goes without them.
547 const steer = Boolean(e.turnId);
548 const result = pasted && !steer ? await next(e) : null;
549 const images = pasted && !steer ? await lastImages($) : [];
550 await send($, { text: e.text, images, skills, steer });
551 return result ?? next(e);
552 });
553
554 // A prompt that waited in Claude Code's queue goes when its turn starts.
555 on("turn.start", async ($, e, next) => {
556 const result = await next(e);
557 if (!isClient()) return result;
558 const at = waiting.findIndex((one) => one.text === e.text);
559 if (at < 0) return result;
560 const [prompt] = waiting.splice(at, 1);
561 if (!prompt) return result;
562 const images = prompt.pasted ? await lastImages($) : [];
563 await send($, { text: prompt.text, images, skills: prompt.skills });
564 return result;
565 });
566
567 // Esc here stops the turn in the sandbox too.
568 on("turn.complete", async ($, e, next) => {
569 if (isClient() && here.id && e.reason === "aborted") {
570 here.skip = here.turnId;
571 await call($, `/sessions/${here.id}/stop`, {});
572 }
573 return next(e);
574 });
575
576 // The Leverage session holds the context, so it compacts, not this window.
577 on("session.compact", async ($, e, next) => {
578 if (!isClient()) return next(e);
579 if (e.trigger !== "manual" || !here.id) {
580 return { skip: "The Leverage session compacts itself." };
581 }
582 await call($, `/sessions/${here.id}/compact`, {});
583 return { skip: "Leverage is compacting this session." };
584 });
585
586 // One window shows one Leverage session, so these act on Leverage.
587 on("command.run", { command: "rename" }, async ($, e, next) => {
588 if (!isClient() || !here.id) return next(e);
589 const title = e.args.trim();
590 if (!title) return { text: "Name the session: /rename <title>" };
591 await call($, `/sessions/${here.id}/rename`, { title });
592 await showLabel($);
593 return { text: `Renamed the Leverage session to ${title}.` };
594 });
595 on("command.run", { command: "resume" }, ($, e, next) =>
596 isClient()
597 ? { text: "/leverage opens another Leverage session in this window." }
598 : next(e),
599 );
600
601 // Claude Code runs a Leverage subagent as its own agent. Its steps come
602 // from the subagent's rows in Leverage.
603 on("agent.spawn", async ($, e, next) => {
604 const subagent = here.agents.get(e.tool_use_id);
605 if (!isClient() || !subagent) return next(e);
606 // In the foreground, as in Leverage: the turn goes on once it ends.
607 const started = next({ ...e, background: false });
608 spawning.add(started);
609 try {
610 const spawned = await started;
611 if (spawned.agentId) here.loops.set(spawned.agentId, subagent);
612 return spawned;
613 } finally {
614 spawning.delete(started);
615 }
616 });
617
618 // Leverage ran the subagent already, so Claude Code asks nobody about it.
619 on("tool.check", { tool: "Agent" }, ($, e, next) =>
620 isClient() && here.agents.has(e.tool_use_id ?? "")
621 ? { decision: "allow" }
622 : next(e),
623 );
624
625 on("turn.step", async function* ($, e, next) {
626 if (!isClient()) return yield* next(e);
627 // A subagent's first step can come before its spawn names it.
628 if (e.agentId && !here.loops.has(e.agentId))
629 await Promise.allSettled([...spawning]);
630 const subagent = e.agentId ? here.loops.get(e.agentId) : undefined;
631 if (e.agentId && !subagent) return yield* next(e);
632 let step: Exclude<Step, { pending: true }>;
633 // Each message drawn as it arrives, at the block index it took.
634 const streamed: {
635 id: string;
636 kind: "text" | "thinking";
637 text: string;
638 index: number;
639 }[] = [];
640 let index = 0;
641 for (;;) {
642 if (next.signal.aborted) throw new Error("aborted");
643 const skip = here.skip ? `&skip=${here.skip}` : "";
644 const known =
645 here.known.size > 0 ? `&known=${[...here.known].join(",")}` : "";
646 const drawn = streamed.map((entry) => `${entry.id}:${entry.text.length}`);
647 const shown =
648 drawn.length > 0 ? `&shown=${encodeURIComponent(drawn.join(","))}` : "";
649 // A subagent's rows are stored whole, so its steps do not stream.
650 const answer = await call<Step>(
651 $,
652 subagent
653 ? `/sessions/${here.id}/step?after=${subagent.cursor}&task=${encodeURIComponent(subagent.task)}${known}`
654 : `/sessions/${here.id}/step?after=${here.cursor}&stream=true${skip}${known}${shown}`,
655 );
656 if (!("pending" in answer)) {
657 step = answer;
658 break;
659 }
660 if (answer.waiting) await askApprovals($, answer.waiting);
661 for (const live of answer.live ?? []) {
662 let entry = streamed.find((one) => one.id === live.id);
663 if (!entry) {
664 const kind = live.kind === "reasoning" ? "thinking" : "text";
665 entry = { id: live.id, kind, text: "", index: index++ };
666 streamed.push(entry);
667 }
668 if (!live.text.startsWith(entry.text)) continue;
669 const piece = live.text.slice(entry.text.length);
670 entry.text = live.text;
671 if (piece) yield { kind: entry.kind, index: entry.index, text: piece };
672 }
673 }
674 if (subagent) {
675 subagent.cursor = step.cursor;
676 if (step.question) here.question = step.question;
677 } else {
678 here.cursor = step.cursor;
679 here.turnId = step.turnId;
680 here.question = step.question;
681 // A first turn names the session.
682 if (step.stop === "end_turn") void showLabel($);
683 }
684 let answer = "";
685 const toolUses: { name: string; input: unknown }[] = [];
686 let matched = 0;
687 for (const block of step.blocks) {
688 if (block.kind === "text" || block.kind === "thinking") {
689 if (block.kind === "text") answer += block.text;
690 // A message streamed already gets the rest of its text.
691 const rest = streamed.slice(matched);
692 const entry = rest.find((one) => one.kind === block.kind);
693 if (entry && block.text.startsWith(entry.text)) {
694 matched = streamed.indexOf(entry) + 1;
695 const piece = block.text.slice(entry.text.length);
696 if (piece)
697 yield { kind: block.kind, index: entry.index, text: piece };
698 } else {
699 yield { kind: block.kind, index: index++, text: block.text };
700 }
701 } else {
702 const name = block.agent ? "Agent" : localName(block.name);
703 const input = block.agent
704 ? {
705 description: block.agent.description || "Leverage subagent",
706 prompt: block.agent.prompt,
707 // Explore runs in the background; Leverage's subagents block their turn.
708 subagent_type: "general-purpose",
709 }
710 : localInput(name, block);
711 if (block.agent) {
712 here.agents.set(block.id, {
713 task: block.agent.task,
714 cursor: step.cursor,
715 });
716 }
717 here.calls.add(block.id);
718 toolUses.push({ name, input });
719 yield { kind: "tool", index, id: block.id, name };
720 yield { kind: "input", index: index++, json: JSON.stringify(input) };
721 }
722 }
723 // A subagent ends its run in Claude Code by handing its report back.
724 let stop = step.stop;
725 if (subagent && stop === "end_turn") {
726 const input = { message: answer || "Done." };
727 toolUses.push({ name: "SubagentHandback", input });
728 const id = `toolu_handback_${subagent.task.replace(/\W/g, "_")}`;
729 yield { kind: "tool", index, id, name: "SubagentHandback" };
730 yield { kind: "input", index: index++, json: JSON.stringify(input) };
731 stop = "tool_use";
732 }
733 yield { kind: "stop", stopReason: stop, usage: usage(step.context) };
734 return {
735 turnId: e.turnId,
736 index: e.index,
737 answer,
738 toolUses,
739 stopReason: stop,
740 usage: usage(step.context),
741 };
742 });
743
744 on("tool.call", async ($, e, next) => {
745 const id = e.tool_use_id ?? "";
746 // Registered tools stay offered after logout; none runs then.
747 if (!isClient() && e.tool.startsWith("mcp__leverage__"))
748 return { deny: "This window is not connected to Leverage." };
749 if (!isClient() || !here.calls.has(id)) return next(e);
750 // Claude Code runs the subagent; its steps come from Leverage.
751 if (here.agents.has(id)) return next(e);
752 const asked = here.question === id;
753 if (asked) here.question = null;
754 if (e.tool === "AskUserQuestion" && asked) {
755 // Claude Code asks it; Leverage takes the answers as the web app does.
756 const answered = await next(e);
757 const answers =
758 (answered as { result?: { answers?: Record<string, string> } }).result
759 ?.answers ?? {};
760 // Answered in another app meanwhile: Leverage keeps that answer.
761 await call($, `/sessions/${here.id}/answers`, {
762 toolUseId: id,
763 answers,
764 }).catch(() => {});
765 return answered;
766 }
767 if (e.tool === "mcp__leverage__ExitPlanMode" && asked) {
768 const answer = await $.ui
769 .ask("Approve this plan?", {
770 header: "Plan",
771 options: ["Approve", "Request changes"],
772 })
773 .catch(() => "");
774 if (answer) {
775 const plan = answer === "Approve" ? "approved" : "changes_requested";
776 await call($, `/sessions/${here.id}/answers`, {
777 toolUseId: id,
778 answers: { plan },
779 });
780 }
781 }
782 const name = String(e.tool).replace(/^mcp__leverage__/, "");
783 $.ui.status(`${line()} · ${name} runs in Leverage`);
784 // The tool's own progress shows on the status line until its result.
785 let seen = 0;
786 let result: Exclude<Result, { pending: true }>;
787 for (;;) {
788 if (next.signal.aborted) throw new Error("aborted");
789 const answer = await call<Result>(
790 $,
791 `/sessions/${here.id}/results/${encodeURIComponent(id)}?stream=true&seen=${seen}`,
792 );
793 if (!("pending" in answer)) {
794 result = answer;
795 break;
796 }
797 if (answer.progress !== undefined) {
798 seen = answer.seen ?? seen;
799 const progress = answer.progress.trim().split("\n").at(-1) ?? "";
800 $.ui.status(`${line()} · ${name}: ${progress.slice(0, 120)}`);
801 }
802 }
803 $.ui.status(line());
804 if (e.tool === "AskUserQuestion") {
805 // Answered in another app: the answers read as that app's reply.
806 const input = e as { questions?: unknown[] };
807 return {
808 result: {
809 questions: input.questions ?? [],
810 answers: {},
811 response: result.content,
812 } as never,
813 };
814 }
815 if (result.isError) return { deny: result.content };
816 const { tool, tool_use_id: _, ...input } = e as Record<string, unknown>;
817 const startLine = tool === "Edit" ? await editLine($, input) : 1;
818 const shaped = NATIVE.has(String(tool))
819 ? nativeResult(String(tool), input, result.content, startLine)
820 : result.content;
821 return { result: shaped as never };
822 });
823};
824hooks/client.ts 268 lines1import type { HttpInit } from "claude-code";
2
3// Where this window reaches Leverage, shared by the plugin's modules. Each
4// module calls $.http.fetch itself: the engine follows $ only within one file.
5
6export type Connection = {
7 /** The app origin. The plugin's routes are under `/api/claude`. */
8 url: string;
9 /** A Claude Code token, which names the person and the workspace. */
10 token: string;
11 /** The space whose first prompt starts a session. */
12 space: string | null;
13};
14
15/** Where Leverage is, unless LEVERAGE_HOST names another. */
16const PRODUCTION = "https://app.leverage.computer";
17
18let connection: Connection | null = null;
19/** Where the connection came from: `leverage claude`, `/leverage login` or the CLI's config. */
20let source: "launched" | "saved" | "kept" | null = null;
21
22/** The session this window shows, and the last row it read. */
23export const here = {
24 id: "",
25 cursor: 0,
26 turnId: null as string | null,
27 /** A turn stopped here. Its remaining rows are not drawn. */
28 skip: null as string | null,
29 /** Approvals this window already asked, so none is asked twice. */
30 known: new Set<string>(),
31 /** The question this person answers in Claude Code's own dialog. */
32 question: null as string | null,
33 /** The tool calls the remote turn made. Any other call runs here. */
34 calls: new Set<string>(),
35 /** The last row the prompt and notice poll read. */
36 polled: 0,
37 /** A skill the next prompt sent from this window loads. */
38 skill: null as string | null,
39 /** When this person last told Leverage they type; 0 when they do not. */
40 typedAt: 0,
41 /** Each Leverage subagent and the last row of it drawn, by its Agent call. */
42 agents: new Map<string, Subagent>(),
43 /** The same subagents, by the Claude Code agent that runs each. */
44 loops: new Map<string, Subagent>(),
45};
46
47/** A Leverage subagent that Claude Code runs as its own agent. */
48export type Subagent = { task: string; cursor: number };
49
50/** The prompts this window sent or already drew. */
51export const drawn = new Set<string>();
52
53/** Whether this window runs a Leverage session's turns, not only the pane. */
54let isClientWindow = false;
55
56/**
57 * Takes the connection `leverage claude` starts Claude Code with. Such a
58 * window runs a session's turns at once.
59 */
60export function connectLaunched(env: {
61 url: string | undefined;
62 token: string | undefined;
63 space: string | undefined;
64 session: string | undefined;
65}): boolean {
66 if (!env.url || !env.token) return false;
67 connection = {
68 url: env.url.replace(/\/+$/, ""),
69 token: env.token,
70 space: env.space || null,
71 };
72 if (env.session) here.id = env.session;
73 source = "launched";
74 isClientWindow = true;
75 return true;
76}
77
78/**
79 * Takes the Claude Code token the Leverage CLI keeps in its config, so any
80 * window, as Claude Desktop's, lists the sessions in the pane. The window
81 * runs a session's turns only once the person opens one there.
82 */
83export function connectKept(config: string): boolean {
84 const kept = keptToken(config);
85 if (!kept) return false;
86 connection = { ...kept, space: null };
87 source = "kept";
88 return true;
89}
90
91/** The Claude Code token in the Leverage CLI's config, and its host; or null. */
92export function keptToken(
93 config: string,
94): { url: string; token: string } | null {
95 let parsed: {
96 currentHost?: string;
97 hosts?: Record<
98 string,
99 {
100 workspaceId?: string;
101 accessTokens?: Record<string, Record<string, string>>;
102 }
103 >;
104 };
105 try {
106 parsed = JSON.parse(config);
107 } catch {
108 return null;
109 }
110 const hosts = parsed.hosts ?? {};
111 // The signed-in host first, then any other that keeps one.
112 for (const host of [parsed.currentHost, ...Object.keys(hosts)]) {
113 if (!host) continue;
114 const profile = hosts[host];
115 const token = profile?.workspaceId
116 ? profile.accessTokens?.claude?.[profile.workspaceId]
117 : undefined;
118 if (!token) continue;
119 return { url: host.replace(/\/+$/, ""), token };
120 }
121 return null;
122}
123
124/**
125 * Takes the token `/leverage login` saved in Claude Code's config. Like the
126 * CLI's, it lists the sessions in the pane until the person opens one.
127 */
128export function connectSaved(saved: string): boolean {
129 let parsed: { url?: string; token?: string };
130 try {
131 parsed = JSON.parse(saved);
132 } catch {
133 return false;
134 }
135 if (!parsed.url || !parsed.token) return false;
136 connection = {
137 url: parsed.url.replace(/\/+$/, ""),
138 token: parsed.token,
139 space: null,
140 };
141 source = "saved";
142 return true;
143}
144
145const isAbsolute = (path: string) => /^(\/|[A-Za-z]:[\\/])/.test(path);
146
147/**
148 * The file `/leverage login` keeps its token in, in Claude Code's config; or
149 * null with no home folder, so no token is ever read from or left in a project.
150 */
151export function accessPath(
152 configDir: string | undefined,
153 home: string | undefined,
154): string | null {
155 const dir = configDir || (home ? `${home}/.claude` : "");
156 return isAbsolute(dir) ? `${dir}/leverage.json` : null;
157}
158
159/** The Leverage CLI's config file; or null with no home folder. */
160export function cliConfigPath(
161 configDir: string | undefined,
162 xdg: string | undefined,
163 home: string | undefined,
164): string | null {
165 const dir =
166 configDir ||
167 (xdg ? `${xdg}/leverage` : home ? `${home}/.config/leverage` : "");
168 return isAbsolute(dir) ? `${dir}/config.json` : null;
169}
170
171/** The Leverage `/leverage login` signs in to: LEVERAGE_HOST's, or the app. */
172export function leverageHost(named: string | undefined): string {
173 const host = named?.trim();
174 if (!host) return PRODUCTION;
175 try {
176 const url = new URL(/^[a-z]+:\/\//i.test(host) ? host : `https://${host}`);
177 return /^https?:$/.test(url.protocol) ? url.origin : PRODUCTION;
178 } catch {
179 return PRODUCTION;
180 }
181}
182
183/** Leaves Leverage: this window is Claude Code's own again. */
184export function disconnect() {
185 connection = null;
186 source = null;
187 isClientWindow = false;
188 follow("", 0);
189}
190
191/** From now on, this window runs the turns of the session it follows. */
192export function becomeClient() {
193 isClientWindow = true;
194}
195
196// Remote tools drawn as tools of this mod. Others go to `remote_tool`.
197export const MIRRORED = [
198 "Grep",
199 "Glob",
200 "Task",
201 "Agent",
202 "TodoWrite",
203 "WebFetch",
204 "WebSearch",
205 "NotebookEdit",
206 "Skill",
207 "ExitPlanMode",
208 "ToolSearch",
209 "leverage_execute",
210];
211
212/** The tools a window that runs Leverage's turns registers, as registered. */
213export const LEVERAGE_TOOLS = [...MIRRORED, "remote_tool"].map((name) => ({
214 name,
215 description: `${name}, run by the Leverage session.`,
216 inputSchema: { type: "object" as const, additionalProperties: true },
217}));
218
219/** Whether the pane and /leverage can reach Leverage. */
220export function isReachable(): boolean {
221 return connection !== null;
222}
223
224export function connectedBy() {
225 return source;
226}
227
228/** Whether this window runs a Leverage session's turns. */
229export function isClient(): boolean {
230 return connection !== null && isClientWindow;
231}
232
233export function space(): string | null {
234 return connection?.space ?? null;
235}
236
237/** Moves this window to a session. Later steps read after `cursor`. */
238export function follow(id: string, cursor: number) {
239 here.id = id;
240 here.cursor = cursor;
241 here.polled = cursor;
242 here.turnId = null;
243 here.skip = null;
244 here.known.clear();
245 here.question = null;
246 here.typedAt = 0;
247 here.agents.clear();
248 here.loops.clear();
249}
250
251export function leverageRequest(
252 path: string,
253 body?: unknown,
254): [string, HttpInit] {
255 if (!connection) throw new Error("This window is not connected to Leverage.");
256 return [
257 `${connection.url}/api/claude${path}`,
258 {
259 method: body === undefined ? "GET" : "POST",
260 headers: {
261 authorization: `Bearer ${connection.token}`,
262 "content-type": "application/json",
263 },
264 body: body === undefined ? undefined : JSON.stringify(body),
265 },
266 ];
267}
268hooks/commands.ts 652 lines1import type { EngineInterface, HttpResponse, On, Timer } from "claude-code";
2import { update } from "claude-code";
3
4import {
5 accessPath,
6 cliConfigPath,
7 connectedBy,
8 connectSaved,
9 disconnect,
10 here,
11 isReachable,
12 keptToken,
13 leverageHost,
14 leverageRequest,
15 space,
16} from "./client";
17import { PANE } from "./pane";
18
19// `/leverage <action>`: what the other Leverage apps do with a session,
20// done on the session this window shows. Bare, it opens the pane.
21
22const HELP = `/leverage open the pane: spaces and their sessions
23/leverage login [workspace] sign in in the browser
24/leverage logout forget the token here
25/leverage approvals ask again for calls that wait on you
26/leverage rename <title> rename this session (/rename does too)
27/leverage archive archive this session; restore brings it back
28/leverage model [id] [effort] show or pick the session's model
29/leverage queue prompts other apps queued for this session
30/leverage queue take <n> take a waiting prompt back
31/leverage queue send <n> send a waiting prompt now
32/leverage outputs files the last turn wrote
33/leverage files [path] this session's files, or the space's
34/leverage find <name> find a file of this session by name
35/leverage read <path> show a file of this session
36/leverage save <path> save a file of this session here
37/leverage run <command> run a shell command in the session's sandbox
38/leverage changes each repository's changes and pull request
39/leverage skills the skills this session can load
40/leverage skill <name> <prompt> send a prompt that loads a skill
41/leverage connectors the workspace's connectors
42/leverage history [more] the session's last turns, then older ones`;
43
44/** The most a file shows in the transcript. */
45const SHOWN_CHARS = 20_000;
46/** Where the next older page of history starts, once one was shown. */
47let olderFrom: number | null = null;
48
49async function call<T>(
50 $: EngineInterface,
51 path: string,
52 body?: unknown,
53): Promise<T> {
54 const res = await $.http.fetch(...leverageRequest(path, body));
55 if (res.status === 401)
56 throw new Error(
57 connectedBy() === "launched"
58 ? "Leverage refused this token. Run leverage claude again."
59 : "Leverage refused this token. /leverage login signs in again.",
60 );
61 if (!res.ok) {
62 const reason = (() => {
63 try {
64 return (JSON.parse(res.text) as { error?: string }).error;
65 } catch {
66 return undefined;
67 }
68 })();
69 throw new Error(reason ?? `Leverage answered ${res.status}`);
70 }
71 return JSON.parse(res.text) as T;
72}
73
74type Approval = { id: string; tool: string; input: unknown };
75type Waiting = { writable: boolean; approvals: Approval[] };
76type Model = { id: string; label: string; efforts: string[] };
77type Entry = { path: string; type?: string };
78type FileRead = { content?: string; isBinary?: boolean; truncated?: boolean };
79
80const quoted = (path: string) => encodeURIComponent(path);
81
82async function approvals($: EngineInterface): Promise<string> {
83 const waiting = await call<Waiting>($, `/sessions/${here.id}/waiting`);
84 if (waiting.approvals.length === 0) return "No call waits for approval.";
85 if (!waiting.writable) return "Calls wait for the session owner's approval.";
86 for (const pending of waiting.approvals) {
87 const answer = await $.ui
88 .ask(
89 `Allow ${pending.tool} to run in Leverage? ${JSON.stringify(pending.input).slice(0, 300)}`,
90 {
91 header: "Approval",
92 options: ["Once", "This session", "Always", "Reject"],
93 },
94 )
95 .catch(() => "");
96 if (!answer) continue;
97 here.known.add(pending.id);
98 const decision =
99 { Once: "once", "This session": "session", Always: "always" }[answer] ??
100 "reject";
101 await call($, `/approvals/${pending.id}`, {
102 decision,
103 ...(answer === "Reject" || decision !== "reject"
104 ? {}
105 : { reason: answer }),
106 });
107 }
108 return "Decided.";
109}
110
111async function model($: EngineInterface, args: string[]): Promise<string> {
112 const [id, effort] = args;
113 if (id) {
114 await call($, `/sessions/${here.id}/model`, {
115 model: id,
116 ...(effort ? { effort } : {}),
117 });
118 return `The session runs ${id}${effort ? ` at ${effort}` : ""} from the next turn.`;
119 }
120 const [models, session] = await Promise.all([
121 call<Model[]>($, "/models"),
122 call<{ model: string | null; effort: string | null }>(
123 $,
124 `/sessions/${here.id}`,
125 ),
126 ]);
127 const lines = models.map(
128 (one) =>
129 `${one.id === session.model ? "●" : "○"} ${one.id} ${one.label}${one.efforts.length ? ` (${one.efforts.join(", ")})` : ""}`,
130 );
131 return [
132 `Now: ${session.model ?? "the space's default"}${session.effort ? ` at ${session.effort}` : ""}`,
133 ...lines,
134 "Pick one: /leverage model <id> [effort]",
135 ].join("\n");
136}
137
138async function queue($: EngineInterface, args: string[]): Promise<string> {
139 const items = await call<{ uuid: string; text: string }[]>(
140 $,
141 `/sessions/${here.id}/queue`,
142 );
143 const [action, number] = args;
144 if (!action) {
145 if (items.length === 0) return "No prompt waits.";
146 return items
147 .map((item, index) => `${index + 1}. ${item.text.slice(0, 120)}`)
148 .join("\n");
149 }
150 const item = items[Number(number) - 1];
151 if (!item) return `No waiting prompt ${number ?? ""}.`;
152 if (action === "take") {
153 await call($, `/sessions/${here.id}/queue/${item.uuid}/cancel`, {});
154 return "Taken back.";
155 }
156 if (action === "send") {
157 await call($, `/sessions/${here.id}/queue/${item.uuid}/send`, {});
158 return "Sent now.";
159 }
160 return "Use: /leverage queue [take|send] <n>";
161}
162
163async function files($: EngineInterface, path: string): Promise<string> {
164 const at = here.id
165 ? `/sessions/${here.id}/files?path=${quoted(path)}`
166 : `/spaces/${space()}/files?path=${quoted(path)}`;
167 const listed = await call<{ entries: Entry[]; truncated?: boolean }>($, at);
168 if (listed.entries.length === 0) return "No files here.";
169 const lines = listed.entries.map(
170 (entry) => `${entry.path}${entry.type === "dir" ? "/" : ""}`,
171 );
172 return [...lines, ...(listed.truncated ? ["…more"] : [])].join("\n");
173}
174
175// A file of the session, or of the space before a session starts.
176async function readFile($: EngineInterface, path: string): Promise<FileRead> {
177 return call<FileRead>(
178 $,
179 here.id
180 ? `/sessions/${here.id}/file?path=${quoted(path)}`
181 : `/spaces/${space()}/file?path=${quoted(path)}`,
182 );
183}
184
185// Shows a file in the transcript, or saves it in the working folder.
186async function readOrSave(
187 $: EngineInterface,
188 action: "read" | "save",
189 path: string,
190): Promise<string> {
191 if (!path) return `Name the file: /leverage ${action} <path>`;
192 const file = await readFile($, path);
193 if (action === "save") {
194 if (file.content === undefined) return "Leverage cannot read that file.";
195 const name = path.split("/").at(-1) ?? "output";
196 await $.fs.write(name, file.content);
197 return `Saved ${name}.`;
198 }
199 if (file.isBinary || file.content === undefined)
200 return "Not a text file. /leverage save keeps it here.";
201 return file.content.length > SHOWN_CHARS
202 ? `${file.content.slice(0, SHOWN_CHARS)}\n…`
203 : file.content;
204}
205
206async function run($: EngineInterface, args: string): Promise<string> {
207 const [action = "", ...rest] = args.trim().split(/\s+/);
208 const tail = args.trim().slice(action.length).trim();
209 if (action === "help") return HELP;
210 if (action === "connectors") {
211 const connectors = await call<{ name: string; disabled: boolean }[]>(
212 $,
213 "/connectors",
214 );
215 return connectors.length === 0
216 ? "No connectors in this workspace."
217 : connectors
218 .map((one) => `${one.disabled ? "○" : "●"} ${one.name}`)
219 .join("\n");
220 }
221 if (!here.id && action === "files") return files($, tail);
222 if (!here.id && (action === "read" || action === "save"))
223 return readOrSave($, action, tail);
224 if (!here.id) return "Send a first prompt, or open a session with /leverage.";
225 switch (action) {
226 case "approvals":
227 return approvals($);
228 case "rename":
229 if (!tail) return "Name the session: /leverage rename <title>";
230 await call($, `/sessions/${here.id}/rename`, { title: tail });
231 $.ui.status(tail);
232 return `Renamed the session to ${tail}.`;
233 case "archive":
234 await call($, `/sessions/${here.id}/archive`, {});
235 return "Archived. /leverage restore brings it back.";
236 case "restore":
237 await call($, `/sessions/${here.id}/restore`, {});
238 return "Restored.";
239 case "model":
240 return model($, rest);
241 case "queue":
242 return queue($, rest);
243 case "outputs": {
244 const outputs = await call<string[]>($, `/sessions/${here.id}/outputs`);
245 return outputs.length === 0
246 ? "The last turn wrote no files."
247 : `${outputs.join("\n")}\n/leverage read <path> shows one; /leverage save <path> keeps it here.`;
248 }
249 case "files":
250 return files($, tail);
251 case "find": {
252 if (!tail) return "Name part of the file: /leverage find <name>";
253 const found = await call<string[]>(
254 $,
255 `/sessions/${here.id}/find?name=${quoted(tail)}`,
256 );
257 return found.length === 0 ? "No file matches." : found.join("\n");
258 }
259 case "read":
260 case "save":
261 return readOrSave($, action, tail);
262 case "run": {
263 if (!tail) return "Name the command: /leverage run <command>";
264 const ran = await call<{ exitCode: number; output: string }>(
265 $,
266 `/sessions/${here.id}/run`,
267 { command: tail },
268 );
269 const output =
270 ran.output.length > SHOWN_CHARS
271 ? `${ran.output.slice(-SHOWN_CHARS)}`
272 : ran.output;
273 return `${output}${output.endsWith("\n") || !output ? "" : "\n"}exit ${ran.exitCode}`;
274 }
275 case "changes": {
276 const repos = await call<
277 {
278 name: string;
279 branch: string | null;
280 changes: {
281 path: string;
282 state: string;
283 additions: number;
284 deletions: number;
285 }[];
286 unpublished: number;
287 pullRequest: {
288 number: number;
289 url: string;
290 state: string;
291 draft: boolean;
292 } | null;
293 }[]
294 >($, `/sessions/${here.id}/changes`);
295 if (repos.length === 0) return "This session has no repository.";
296 return repos
297 .map((repo) => {
298 const head = `${repo.name}${repo.branch ? ` (${repo.branch})` : ""}`;
299 const pr = repo.pullRequest
300 ? `PR #${repo.pullRequest.number} ${repo.pullRequest.draft ? "draft" : repo.pullRequest.state}: ${repo.pullRequest.url}`
301 : "No pull request.";
302 const changed = repo.changes.map(
303 (change) =>
304 ` ${change.state} ${change.path} +${change.additions} -${change.deletions}`,
305 );
306 const unpublished =
307 repo.unpublished > 0
308 ? [` ${repo.unpublished} change(s) not in the pull request`]
309 : [];
310 return [
311 head,
312 ...(changed.length ? changed : [" no changes"]),
313 ...unpublished,
314 pr,
315 ].join("\n");
316 })
317 .join("\n\n");
318 }
319 case "skills": {
320 const skills = await call<{ name: string; description: string }[]>(
321 $,
322 `/sessions/${here.id}/skills`,
323 );
324 return skills.length === 0
325 ? "No skills."
326 : `${skills.map((one) => `${one.name} ${one.description}`).join("\n")}\n/leverage skill <name> <prompt> sends a prompt that loads one.`;
327 }
328 case "skill": {
329 const [name, ...words] = rest;
330 const prompt = words.join(" ");
331 if (!name || !prompt) return "Use: /leverage skill <name> <prompt>";
332 // The prompt goes through this window, so Claude Code draws its turn.
333 here.skill = name;
334 await $.prompt.submit({ text: prompt, asUser: true });
335 return `Sent with the ${name} skill.`;
336 }
337 case "history": {
338 if (tail === "more" && olderFrom === null) return "No older turns.";
339 const from = tail === "more" ? `&before=${olderFrom}` : "";
340 const page = await call<{
341 messages: { author: string; text: string }[];
342 before: number | null;
343 }>($, `/sessions/${here.id}/history?turns=5${from}`);
344 olderFrom = page.before;
345 const text = page.messages
346 .map((message) => `${message.author}: ${message.text}`)
347 .join("\n\n");
348 return `${text || "No turns yet."}${page.before === null ? "" : "\n\n/leverage history more shows older turns."}`;
349 }
350 }
351 return HELP;
352}
353
354// `/leverage login` and `/leverage logout`: the token this plugin keeps in
355// Claude Code's config. Login signs in in the browser, as `leverage login`
356// does, and trades that sign-in for a Claude Code token of one workspace.
357
358type Grant = {
359 device_code: string;
360 user_code: string;
361 expires_in: number;
362 interval: number;
363};
364type Workspace = { id: string; slug: string; name: string };
365
366/** The browser sign-in that waits for its approval, if one does. */
367let waiting: Timer | null = null;
368/** Counts sign-ins; login and logout move it on, so an older one keeps nothing. */
369let attempt = 0;
370
371function stopWaiting() {
372 waiting?.cancel();
373 waiting = null;
374 attempt++;
375}
376
377async function accessFile($: EngineInterface): Promise<string | null> {
378 return accessPath(
379 await $.env.get("CLAUDE_CONFIG_DIR"),
380 await $.env.get("HOME"),
381 );
382}
383
384async function cliHasToken($: EngineInterface): Promise<boolean> {
385 const file = cliConfigPath(
386 await $.env.get("LEVERAGE_CONFIG_DIR"),
387 await $.env.get("XDG_CONFIG_HOME"),
388 await $.env.get("HOME"),
389 );
390 const config = file ? await $.fs.read(file).catch(() => "{}") : "{}";
391 return keptToken(config) !== null;
392}
393
394function send(
395 $: EngineInterface,
396 url: string,
397 body?: unknown,
398 token?: string,
399): Promise<HttpResponse> {
400 return $.http.fetch(url, {
401 method: body === undefined ? "GET" : "POST",
402 headers: {
403 "content-type": "application/json",
404 ...(token ? { authorization: `Bearer ${token}` } : {}),
405 },
406 body: body === undefined ? undefined : JSON.stringify(body),
407 });
408}
409
410// Opens the approval page. Where no browser opens, the reply has the link.
411async function browse($: EngineInterface, link: string) {
412 for (const opener of ["open", "xdg-open"]) {
413 const opened = await $.process
414 .run([opener, link], { timeoutMs: 5000 })
415 .catch(() => null);
416 if (opened?.exitCode === 0) return;
417 }
418}
419
420// Keeps the token where only this person reads it, and connects this window.
421async function keep(
422 $: EngineInterface,
423 url: string,
424 token: string,
425 live: () => boolean,
426): Promise<string> {
427 const checked = await send(
428 $,
429 `${url}/api/claude/workspace`,
430 undefined,
431 token,
432 );
433 if (checked.status === 401) return "Leverage refused that token.";
434 if (!checked.ok) return `Leverage answered ${checked.status}.`;
435 const { workspace } = JSON.parse(checked.text) as { workspace: string };
436 const file = await accessFile($);
437 if (!file) return "No home folder to keep the token in.";
438 if (!live()) return "";
439 const saved = `${JSON.stringify({ url, token }, null, "\t")}\n`;
440 // Windows keeps a profile's files to its owner, and has no sh.
441 const written = /^[A-Za-z]:/.test(file)
442 ? await $.fs.write(file, saved).then(
443 () => ({ exitCode: 0 }),
444 () => null,
445 )
446 : await $.process
447 .run(
448 [
449 "/bin/sh",
450 "-c",
451 'umask 077 && mkdir -p "$(dirname "$1")" && cat > "$1" && chmod 600 "$1"',
452 "sh",
453 file,
454 ],
455 { stdin: saved },
456 )
457 .catch(() => null);
458 if (written?.exitCode !== 0) return `Could not keep the token in ${file}.`;
459 if (!live()) {
460 await $.process.run(["rm", "-f", file]).catch(() => null);
461 return "";
462 }
463 // A new token may name another workspace: nothing of the last one stays.
464 disconnect();
465 await update($, { plugin: "leverage", key: "tree" }, () => null);
466 connectSaved(saved);
467 void $.ui.open({ id: PANE, title: "Leverage" });
468 return `Signed in to ${workspace}. /leverage opens the pane.`;
469}
470
471// The workspace named, the only one, or the one the person picks.
472async function choose(
473 $: EngineInterface,
474 workspaces: Workspace[],
475 named: string,
476): Promise<Workspace | string> {
477 if (named) {
478 const found =
479 workspaces.find((one) => one.slug === named) ??
480 workspaces.find((one) => one.name === named);
481 return found ?? `No workspace ${named}.`;
482 }
483 if (workspaces.length === 1) return workspaces[0] as Workspace;
484 // Claude Code's dialog shows four at most.
485 if (workspaces.length > 4) {
486 const slugs = workspaces.map((one) => one.slug).join(", ");
487 return `Name the workspace: /leverage login <workspace>. Yours: ${slugs}.`;
488 }
489 // Two workspaces may share a name, never a slug.
490 const labels = workspaces.map((one) =>
491 workspaces.filter((other) => other.name === one.name).length > 1
492 ? `${one.name} (${one.slug})`
493 : one.name,
494 );
495 const picked = await $.ui
496 .ask("Which Leverage workspace?", { header: "Leverage", options: labels })
497 .catch(() => "");
498 return workspaces[labels.indexOf(picked)] ?? "Sign-in stopped.";
499}
500
501// Trades the browser sign-in for a Claude Code token of one workspace.
502async function trade(
503 $: EngineInterface,
504 url: string,
505 access: string,
506 named: string,
507 live: () => boolean,
508): Promise<string> {
509 const listed = await send($, `${url}/api/workspaces`, undefined, access);
510 if (!listed.ok) return `Leverage answered ${listed.status}.`;
511 const workspaces = JSON.parse(listed.text) as Workspace[];
512 if (workspaces.length === 0)
513 return "Your Leverage account has no workspace yet.";
514 const chosen = await choose($, workspaces, named);
515 if (typeof chosen === "string") return chosen;
516 if (!live()) return "";
517 const minted = await send(
518 $,
519 `${url}/api/workspaces/${chosen.id}/claude/token`,
520 {},
521 access,
522 );
523 if (!minted.ok) return `Leverage answered ${minted.status}.`;
524 return keep(
525 $,
526 url,
527 (JSON.parse(minted.text) as { token: string }).token,
528 live,
529 );
530}
531
532// Asks every few seconds whether the person approved the code.
533function awaitApproval(
534 $: EngineInterface,
535 url: string,
536 grant: Grant,
537 named: string,
538) {
539 let asked = 0;
540 let skip = 0;
541 let busy = false;
542 const mine = attempt;
543 const live = () => mine === attempt;
544 const end = (text: string) => {
545 timer.cancel();
546 if (waiting === timer) waiting = null;
547 if (live() && text) $.ui.toast(text, { timeoutMs: 8000 });
548 };
549 const timer = $.clock.every(grant.interval * 1000, async () => {
550 if (busy) return;
551 if (skip > 0) {
552 skip--;
553 return;
554 }
555 if (++asked * grant.interval > grant.expires_in)
556 return end("The sign-in code expired. /leverage login starts again.");
557 busy = true;
558 try {
559 // A code is good once, so its answer is always read to the end. A
560 // network error leaves the code waiting: the next tick asks again.
561 const res = await send($, `${url}/api/cli/auth/token`, {
562 device_code: grant.device_code,
563 }).catch(() => null);
564 if (!res) return;
565 // Not approved yet, or Leverage is busy: ask again later.
566 if (res.status === 428 || res.status >= 500) return;
567 if (res.status === 429) {
568 skip = 1;
569 return;
570 }
571 if (res.status === 410 && res.text.includes("denied"))
572 return end("Sign-in denied.");
573 if (res.status === 410 || res.status === 404)
574 return end("The sign-in code expired. /leverage login starts again.");
575 if (!res.ok) return end(`Leverage answered ${res.status}.`);
576 timer.cancel();
577 const { access_token } = JSON.parse(res.text) as {
578 access_token: string;
579 };
580 end(await trade($, url, access_token, named, live));
581 } catch (error) {
582 end(`Leverage: ${(error as Error).message}`);
583 } finally {
584 busy = false;
585 }
586 });
587 waiting = timer;
588}
589
590/** `/leverage login [workspace]`. A token is never typed: the transcript keeps commands. */
591async function login($: EngineInterface, arg: string): Promise<string> {
592 if (connectedBy() === "launched")
593 return "This window runs with the token leverage claude started it with.";
594 stopWaiting();
595 if (!(await accessFile($))) return "No home folder to keep the token in.";
596 const url = leverageHost(await $.env.get("LEVERAGE_HOST"));
597 const started = await send($, `${url}/api/cli/auth/device`, {
598 client_name: "Claude Code",
599 });
600 if (started.status === 429)
601 return "Leverage asks you to wait a minute, then sign in again.";
602 if (!started.ok) return `Leverage answered ${started.status}.`;
603 const grant = JSON.parse(started.text) as Grant;
604 const link = `${url}/device?user_code=${encodeURIComponent(grant.user_code)}`;
605 await browse($, link);
606 awaitApproval($, url, grant, arg);
607 return `Confirm ${grant.user_code} in the browser: ${link}`;
608}
609
610/** `/leverage logout`: forgets the token and leaves Leverage in this window. */
611async function logout($: EngineInterface): Promise<string> {
612 if (connectedBy() === "launched")
613 return "This window runs with the token leverage claude started it with.";
614 stopWaiting();
615 const file = await accessFile($);
616 if (file && (await $.fs.exists(file).catch(() => false))) {
617 const removed = await $.process.run(["rm", "-f", file]).catch(() => null);
618 if (removed?.exitCode !== 0) await $.fs.write(file, "{}\n");
619 }
620 disconnect();
621 await $.ui.close({ id: PANE }).catch(() => {});
622 $.ui.status(undefined);
623 // The token itself ends only when Leverage revokes it.
624 const revoke = "Leverage Settings → Integrations revokes the token.";
625 return (await cliHasToken($))
626 ? `Signed out here. ${revoke} The Leverage CLI keeps its own: leverage logout ends it.`
627 : `Signed out. ${revoke}`;
628}
629
630export function registerCommands(on: On) {
631 on("command.run", { command: "leverage" }, async ($, e) => {
632 const [action = "", ...rest] = e.args.trim().split(/\s+/);
633 try {
634 if (action === "login") return { text: await login($, rest.join(" ")) };
635 if (action === "logout") return { text: await logout($) };
636 } catch (error) {
637 return { text: `Leverage: ${(error as Error).message}` };
638 }
639 if (!isReachable())
640 return { text: "Not signed in to Leverage. /leverage login signs in." };
641 if (!e.args.trim()) {
642 await $.ui.open({ id: PANE, title: "Leverage" });
643 return { text: "Leverage pane opened. /leverage help lists the rest." };
644 }
645 try {
646 return { text: await run($, e.args) };
647 } catch (error) {
648 return { text: `Leverage: ${(error as Error).message}` };
649 }
650 });
651}
652hooks/pane.tsx 252 lines1import type { EngineInterface, On } from "claude-code";
2import { atom, read, update } from "claude-code";
3
4import type { PaneSession, WorkspaceTree } from "../types";
5import {
6 becomeClient,
7 follow,
8 here,
9 isClient,
10 isReachable,
11 LEVERAGE_TOOLS,
12 leverageRequest,
13} from "./client";
14
15// The Leverage pane, drawn as Claude's session sidebar: the workspace's
16// channels and their sessions. A press on a session moves this window to
17// that session.
18
19export const PANE = "leverage";
20let isRefreshing = false;
21const tree = atom({ plugin: "leverage", key: "tree" } as const, null);
22/** The text the session titles must hold; empty lists them all. */
23const search = atom({ plugin: "leverage", key: "search" } as const, "");
24/** The spaces that list all their sessions, not the first few. */
25const more = atom({ plugin: "leverage", key: "more" } as const, [] as string[]);
26/** How many sessions a space lists before its "more" row. */
27const FEW = 8;
28
29// As in Claude's sidebar, only a session that works or waits shows a mark.
30const MARK: Record<string, string> = {
31 active: "claude",
32 running: "claude",
33 awaiting_input: "permission",
34};
35
36async function call<T>(
37 $: EngineInterface,
38 path: string,
39 body?: unknown,
40): Promise<T> {
41 const res = await $.http.fetch(...leverageRequest(path, body));
42 if (!res.ok) {
43 const reason = (() => {
44 try {
45 return (JSON.parse(res.text) as { error?: string }).error;
46 } catch {
47 return undefined;
48 }
49 })();
50 throw new Error(reason ?? `Leverage answered ${res.status}`);
51 }
52 return JSON.parse(res.text) as T;
53}
54
55async function refresh($: EngineInterface) {
56 if (!isReachable()) return;
57 const asked = await read($, search);
58 const next = await call<Omit<WorkspaceTree, "attached">>(
59 $,
60 `/workspace${asked ? `?query=${encodeURIComponent(asked)}` : ""}`,
61 );
62 await update($, tree, () => ({ ...next, attached: here.id }));
63}
64
65/** A row's fill when it is the session here, or under the pointer. */
66const LIT: Record<string, string> = {
67 terminal: "userMessageBackground",
68 other: "promptBorderShimmer",
69};
70/** No color: a row's border that only rounds its fill. */
71const CLEAR = "#00000000";
72
73export function registerPane(on: On) {
74 on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
75 const elements = $.ui.resolve(e);
76 const { Box, Text, Button } = elements;
77 const isTerminal = e.surface === "terminal";
78 // A phone has no text field: it lists every session.
79 const Input = "Input" in elements ? elements.Input : null;
80 const lit = isTerminal ? LIT.terminal : LIT.other;
81 const asked = await read($, search);
82 const open = await read($, more);
83 // The first drawing starts the refresh. A timer outlives this dispatch.
84 if (!isRefreshing) {
85 isRefreshing = true;
86 void refresh($);
87 $.clock.every(5000, () => refresh($));
88 }
89 const data = await read($, tree);
90 if (!data) {
91 return (
92 <Text dimColor wrap="wrap">
93 Connecting to Leverage… If it never does, /leverage login signs in.
94 </Text>
95 );
96 }
97
98 const attach = async (session: PaneSession) => {
99 if (session.id === data.attached) return;
100 // The window starts at the session's newest row.
101 const opened = await call<{ id: string; cursor: number }>(
102 $,
103 `/sessions/${session.id}`,
104 );
105 follow(opened.id, opened.cursor);
106 // From here the window runs the session's turns in Leverage.
107 if (!isClient()) {
108 for (const tool of LEVERAGE_TOOLS) await $.tool.register(tool);
109 becomeClient();
110 }
111 // One window mirrors one session, so the old transcript goes.
112 await $.command.run({ command: "clear" });
113 $.ui.status(session.title);
114 // Opened, it reads as read, and its last turns show as one notice.
115 await call($, `/sessions/${session.id}/read`, {});
116 const { messages } = await call<{
117 messages: { author: string; text: string }[];
118 }>($, `/sessions/${session.id}/history`);
119 if (messages.length > 0) {
120 const text = messages
121 .map((message) => `${message.author}: ${message.text}`)
122 .join("\n\n");
123 await $.session.append({
124 message: {
125 type: "system",
126 content: [
127 { type: "text", text: `Earlier in this session:\n\n${text}` },
128 ],
129 },
130 });
131 }
132 await refresh($);
133 };
134
135 /** A one-column mark in a theme color, or the room for one. */
136 const mark = (key: string, color: string | undefined, glyph = "●") => (
137 <Box key={key} width={1} flexShrink={0}>
138 {color ? <Text color={color}>{glyph}</Text> : null}
139 </Box>
140 );
141
142 const row = (session: PaneSession) => {
143 const isHere = session.id === data.attached;
144 return (
145 <Box
146 key={`row-${session.id}`}
147 flexDirection="row"
148 alignItems="center"
149 gap={1}
150 paddingX={1}
151 {...(isTerminal
152 ? {}
153 : {
154 minHeight: 1.5,
155 borderStyle: "round",
156 borderColor: CLEAR,
157 paddingY: 0,
158 })}
159 {...(isHere ? { backgroundColor: lit } : {})}
160 hover={{ backgroundColor: lit }}
161 >
162 {mark(`mark-${session.id}`, MARK[session.status])}
163 <Box flexGrow={1} flexShrink={1} minWidth={0}>
164 <Button
165 key={isHere ? `here-${session.id}` : `s-${session.id}`}
166 plain
167 label={session.title}
168 hover={{ backgroundColor: CLEAR }}
169 onPress={() => (isHere ? refresh($) : attach(session))}
170 />
171 </Box>
172 </Box>
173 );
174 };
175
176 const sessions = (owner: string, list: PaneSession[]) => {
177 const listed = open.includes(owner) ? list : list.slice(0, FEW);
178 return [
179 ...listed.map(row),
180 ...(list.length > listed.length
181 ? [
182 <Box key={`more-row-${owner}`} paddingLeft={4}>
183 <Button
184 key={`more-${owner}`}
185 label={`${list.length - listed.length} more`}
186 plain
187 dimColor
188 onPress={() =>
189 void update($, more, (ids) => [...(ids ?? []), owner])
190 }
191 />
192 </Box>,
193 ]
194 : []),
195 ];
196 };
197
198 /** A space's name. */
199 const heading = (key: string, name: string) => (
200 <Box key={key} paddingX={1} marginTop={1}>
201 <Text dimColor bold>
202 {name}
203 </Text>
204 </Box>
205 );
206
207 return (
208 <Box flexDirection="column">
209 <Box
210 flexDirection="row"
211 alignItems="center"
212 justifyContent="space-between"
213 gap={2}
214 paddingX={1}
215 >
216 <Text bold wrap="truncate">
217 {data.workspace}
218 </Text>
219 {Input ? (
220 <Input
221 key="search"
222 placeholder="Search sessions"
223 value={asked}
224 onSubmit={async (value: string) => {
225 await update($, search, () => value.trim());
226 await refresh($);
227 }}
228 />
229 ) : null}
230 </Box>
231 {data.channels.map((channel) => (
232 <Box key={channel.id} flexDirection="column">
233 {heading(`heading-${channel.id}`, `#${channel.name}`)}
234 {sessions(channel.id, channel.sessions)}
235 {channel.sessions.length === 0 ? (
236 <Box paddingLeft={4}>
237 <Text dimColor>No sessions yet</Text>
238 </Box>
239 ) : null}
240 </Box>
241 ))}
242 {data.direct.length > 0 && (
243 <Box flexDirection="column">
244 {heading("heading-direct", "Private")}
245 {sessions("direct", data.direct)}
246 </Box>
247 )}
248 </Box>
249 );
250 });
251}
252hooks/people.tsx 99 lines1import type { EngineInterface, On } from "claude-code";
2import { atom, read } from "claude-code";
3
4import type { Room, Viewer } from "../types";
5import { here, isClient, leverageRequest } from "./client";
6
7// Who else is in the Leverage session, and who types there, above the prompt.
8// The others see this person type, as in their own apps.
9
10/** What the last prompt poll saw. */
11const people = atom(
12 { plugin: "leverage", key: "people" } as const,
13 {
14 viewers: [],
15 typing: [],
16 } as Room,
17);
18
19/** Leverage forgets a typing signal after 5 s, so a typist sends it again sooner. */
20const TYPING_MS = 3_000;
21
22const APP: Record<string, string> = {
23 "leverage/web": "web",
24 "leverage/desktop": "desktop app",
25 "leverage/mobile": "phone",
26 "leverage/cli": "terminal",
27 codex: "Codex",
28 opencode: "OpenCode",
29 claude: "Claude Code",
30};
31
32/** What shows after a name: " (idle, on web & Codex)". The web alone is not said. */
33function about(viewer: Viewer): string {
34 const apps = viewer.apps.filter((app) => Object.hasOwn(APP, app));
35 const where = apps.some((app) => app !== "leverage/web")
36 ? `on ${apps.map((app) => APP[app]).join(" & ")}`
37 : "";
38 const parts = [viewer.state === "idle" ? "idle" : "", where].filter(Boolean);
39 return parts.length > 0 ? ` (${parts.join(", ")})` : "";
40}
41
42function typingLine(names: string[]): string {
43 if (names.length === 1) return `${names[0]} is typing…`;
44 return `${names.slice(0, -1).join(", ")} and ${names.at(-1)} are typing…`;
45}
46
47async function signal($: EngineInterface, active: boolean) {
48 if (!here.id) return;
49 await $.http
50 .fetch(...leverageRequest(`/sessions/${here.id}/typing`, { active }))
51 .catch(() => {});
52}
53
54export function registerPeople(on: On) {
55 on("prompt.edit", async ($, e, next) => {
56 const box = await next(e);
57 if (!isClient() || !here.id) return box;
58 const now = await $.clock.now();
59 if (!box.text.trim()) {
60 if (here.typedAt) void signal($, false);
61 here.typedAt = 0;
62 } else if (now - here.typedAt > TYPING_MS) {
63 here.typedAt = now;
64 void signal($, true);
65 }
66 return box;
67 });
68
69 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
70 if (!isClient() || e.props.hasSurvey) return next(e);
71 const { viewers, typing } = await read($, people);
72 if (viewers.length === 0 && typing.length === 0) return next(e);
73 const { Box, Text } = $.ui.resolve(e);
74 return (
75 <Box flexDirection="column">
76 {viewers.length > 0 ? (
77 <Text wrap="truncate">
78 {viewers.map((viewer, at) => (
79 <Text key={viewer.name}>
80 {at > 0 ? " · " : ""}
81 <Text color={viewer.state === "idle" ? "warning" : "success"}>
82 {viewer.state === "idle" ? "◐" : "●"}
83 </Text>{" "}
84 {viewer.name}
85 <Text dimColor>{about(viewer)}</Text>
86 </Text>
87 ))}
88 </Text>
89 ) : null}
90 {typing.length > 0 ? (
91 <Text dimColor italic>
92 {typingLine(typing)}
93 </Text>
94 ) : null}
95 </Box>
96 );
97 });
98}
99types/index.d.ts 29 lines1/** Someone else who has the Leverage session open, and the apps they use. */
2export type Viewer = { name: string; state: "active" | "idle"; apps: string[] };
3/** Who else has the session open, and the names of those who type in it. */
4export type Room = { viewers: Viewer[]; typing: string[] };
5
6export type PaneSession = { id: string; title: string; status: string };
7export type PaneChannel = {
8 id: string;
9 name: string;
10 sessions: PaneSession[];
11};
12export type WorkspaceTree = {
13 workspace: string;
14 attached: string;
15 channels: PaneChannel[];
16 direct: PaneSession[];
17};
18
19declare module "claude-code" {
20 interface PluginState {
21 leverage: {
22 tree: WorkspaceTree | null;
23 search: string;
24 more: string[];
25 people: Room;
26 };
27 }
28}
29