SLOPSHOPPER

leverage

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

newpanebandguardcommandtoast
v0.14.0MITupdated 2026-10-09leverage-computer/claude-plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · leverage
│ ┃ leverage ✕ › fix the failing auth test and add an audit log call │ ┃ Connecting to Leverage… If it never does, │ ┃ /leverage login signs in. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /leverage │ ⎿ leverage: Not signed in to Leverage. /leverage login signs in. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · leverage
Connecting to Leverage… If it never does, /leverage login signs in.
README

<picture><source media="(prefers-color-scheme: dark)" srcset="docs/leverage-dark.svg"><img src="docs/leverage-light.svg" alt="Leverage Computer" height="24"></picture> Leverage for Claude Code

<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

Setting up Leverage in Claude Code: add the marketplace, install the plugin, open a session from /leverage, and prompt it

Install

Requirements

  • Claude Code 2.1.286 or later
  • A Leverage account
claude plugin marketplace add leverage-computer/claude-plugin
claude plugin install leverage@leverage

Usage

  • /leverage login: sign in, and approve in the browser.
  • /leverage: open the pane.
  • Press a session to run it here: click it, or ctrl+x tab, Tab to it, Enter.

To start a new session in a space, use the Leverage CLI:

leverage claude <space>

Features

  • One session, two places. Text and thinking stream as Leverage writes them.

Starting a session in a space and getting an answer

  • Teammates. Shared visibility of our messages, status, typing indicators inside of Claude Code.

A teammate joins the session, types, and sends a prompt from Leverage

  • Steering. Enter over a running turn steers it. ctrl+x enter keeps the prompt in Claude Code's queue, where you can edit it or take it back, until its turn starts.
  • Approvals and questions show in Claude Code's own dialogs.

Approving a command in Claude Code's own dialog

  • Subagents run Leverage subagents as Claude Code's own agents.
  • The Leverage pane lists your spaces, their sessions. /leverage opens the pane again.

Opening the pane, jumping to a session, and working in it

  • /leverage help lists the actions you can do with Leverage mod.

License

MIT

Source 6 files
hooks/register.ts 824 lines
1import 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};
824
hooks/client.ts 268 lines
1import 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}
268
hooks/commands.ts 652 lines
1import 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}
652
hooks/pane.tsx 252 lines
1import 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}
252
hooks/people.tsx 99 lines
1import 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}
99
types/index.d.ts 29 lines
1/** 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