@yannelli/paseo-linear-plugin 1.1.0-beta.0 → 1.1.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/OVERVIEW.md +7 -3
  2. package/README.md +38 -11
  3. package/client/access.tsx +2 -1
  4. package/client/compat.ts +23 -5
  5. package/client/connect.tsx +3 -2
  6. package/client/graph-camera.tsx +219 -0
  7. package/client/graph-labels.ts +14 -4
  8. package/client/launch-guidance.tsx +86 -0
  9. package/client/launch-plan.ts +50 -2
  10. package/client/launch.tsx +10 -1
  11. package/client/live-agents.tsx +138 -23
  12. package/client/live-cursor.tsx +3 -1
  13. package/client/live-graph.tsx +159 -75
  14. package/client/live-header.tsx +29 -0
  15. package/client/live-panel.tsx +29 -8
  16. package/client/live-timeline.ts +38 -3
  17. package/client/pill.tsx +4 -3
  18. package/client/plugin-icon.tsx +43 -0
  19. package/client/settings-fields.tsx +11 -3
  20. package/client/settings-icon.tsx +228 -0
  21. package/client/settings-live.tsx +25 -1
  22. package/client/timeline-card.tsx +2 -1
  23. package/client/tool-buttons.tsx +68 -0
  24. package/client/ui.tsx +16 -7
  25. package/client/web.ts +91 -0
  26. package/index.client.tsx +9 -0
  27. package/index.server.ts +5 -1
  28. package/package.json +1 -1
  29. package/server/agent-hooks.ts +37 -0
  30. package/server/agent-runs.ts +15 -2
  31. package/server/agent-tools.ts +240 -0
  32. package/server/init.ts +1 -0
  33. package/server/live.ts +27 -1
  34. package/server/mcp-http.ts +152 -0
  35. package/server/stores.ts +8 -1
  36. package/server/subagent-logs.ts +146 -0
  37. package/shared/activity.ts +5 -1
  38. package/shared/agent-hooks.ts +68 -0
  39. package/shared/agent-tools.ts +130 -0
  40. package/shared/custom-icon.ts +148 -0
  41. package/shared/graph-camera.ts +93 -0
  42. package/shared/live.ts +43 -0
  43. package/shared/prompts.ts +49 -1
  44. package/shared/settings.ts +34 -0
@@ -0,0 +1,146 @@
1
+ import { open, readdir, readFile, stat } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+ import type { SubagentLog } from "../shared/live";
5
+
6
+ // Claude writes each subagent's transcript beside the session, also for subagents that run in
7
+ // the background and send nothing to the parent timeline. This reads their tool calls and turns
8
+ // them into timeline items the panel already understands. Nothing here writes or runs anything.
9
+
10
+ const SESSION_ID = /^[A-Za-z0-9-]{8,80}$/;
11
+ const MAX_RUNS = 8;
12
+ const MAX_TAIL_BYTES = 512 * 1024;
13
+ const MAX_ITEMS = 120;
14
+
15
+ /** Folders that can hold Claude's `projects` folder: the env setting, the default, Paseo homes. */
16
+ async function claudeHomes(): Promise<string[]> {
17
+ const homes = [process.env.CLAUDE_CONFIG_DIR, path.join(homedir(), ".claude")];
18
+ const paseoHome = process.env.PASEO_HOME || path.join(homedir(), ".paseo");
19
+ const accounts = await readdir(path.join(paseoHome, "zerosub", "homes")).catch(() => []);
20
+ for (const name of accounts) homes.push(path.join(paseoHome, "zerosub", "homes", name));
21
+ return [...new Set(homes.filter((home): home is string => Boolean(home)))];
22
+ }
23
+
24
+ const found = new Map<string, string>();
25
+
26
+ async function isDirectory(target: string): Promise<boolean> {
27
+ return (await stat(target).catch(() => null))?.isDirectory() ?? false;
28
+ }
29
+
30
+ /** The session's `subagents` folder. Claude names the project folder after the cwd. */
31
+ export async function subagentFolder(sessionId: string, cwd: string): Promise<string | null> {
32
+ if (!SESSION_ID.test(sessionId)) return null;
33
+ const known = found.get(sessionId);
34
+ if (known) return known;
35
+ const slug = cwd.replace(/[^A-Za-z0-9]/g, "-");
36
+ for (const home of await claudeHomes()) {
37
+ const projects = path.join(home, "projects");
38
+ const guess = path.join(projects, slug, sessionId, "subagents");
39
+ const names = (await isDirectory(guess)) ? [slug] : await readdir(projects).catch(() => []);
40
+ for (const name of names) {
41
+ const folder = path.join(projects, name, sessionId, "subagents");
42
+ if (await isDirectory(folder)) {
43
+ found.set(sessionId, folder);
44
+ return folder;
45
+ }
46
+ }
47
+ }
48
+ return null;
49
+ }
50
+
51
+ async function tail(file: string): Promise<string> {
52
+ const handle = await open(file, "r");
53
+ try {
54
+ const { size } = await handle.stat();
55
+ const start = Math.max(0, size - MAX_TAIL_BYTES);
56
+ const buffer = Buffer.alloc(size - start);
57
+ await handle.read(buffer, 0, buffer.length, start);
58
+ const text = buffer.toString("utf8");
59
+ // A cut first line is not valid JSON; drop it.
60
+ return start > 0 ? text.slice(text.indexOf("\n") + 1) : text;
61
+ } finally {
62
+ await handle.close();
63
+ }
64
+ }
65
+
66
+ type Block = Record<string, unknown>;
67
+ type Item = SubagentLog["items"][number];
68
+
69
+ const str = (value: unknown) => (typeof value === "string" ? value : "");
70
+ const lines = (value: unknown) => (str(value) ? str(value).replace(/\n$/, "").split("\n").length : 0);
71
+
72
+ /** A Claude tool call as a Paseo-style tool call item, with paths moved from the worktree. */
73
+ export function toolItem(block: Block, move: (value: string) => string, cwd: string): Item {
74
+ const input = (block.input ?? {}) as Block;
75
+ const name = str(block.name);
76
+ const file = move(str(input.file_path) || str(input.notebook_path));
77
+ let detail: Record<string, unknown> = { type: "unknown" };
78
+ if (name === "Read" && file) detail = { type: "read", filePath: file };
79
+ else if ((name === "Edit" || name === "MultiEdit" || name === "NotebookEdit") && file) {
80
+ const edits = Array.isArray(input.edits) ? (input.edits as Block[]) : [input];
81
+ const added = edits.reduce((sum, edit) => sum + lines(edit.new_string ?? edit.new_source), 0);
82
+ const removed = edits.reduce((sum, edit) => sum + lines(edit.old_string), 0);
83
+ detail = { type: "edit", filePath: file, added, removed };
84
+ } else if (name === "Write" && file) detail = { type: "write", filePath: file, added: lines(input.content) };
85
+ else if (name === "Grep" || name === "Glob") detail = { type: "search", query: str(input.pattern) };
86
+ else if (name === "Bash") detail = { type: "shell", command: move(str(input.command)), cwd };
87
+ return { type: "tool_call", callId: str(block.id), name, status: "running", detail };
88
+ }
89
+
90
+ /** Tool calls in a transcript, newest last, with each finished call marked done or failed. */
91
+ export function transcriptItems(text: string, move: (value: string) => string, cwd: string) {
92
+ const items = new Map<string, Item>();
93
+ let finished = false;
94
+ for (const line of text.split("\n")) {
95
+ if (!line.trim()) continue;
96
+ let entry: Block;
97
+ try {
98
+ entry = JSON.parse(line) as Block;
99
+ } catch {
100
+ continue;
101
+ }
102
+ const message = (entry.message ?? {}) as Block;
103
+ const content = Array.isArray(message.content) ? (message.content as Block[]) : [];
104
+ if (entry.type === "assistant") {
105
+ finished = message.stop_reason === "end_turn" && !content.some((block) => block.type === "tool_use");
106
+ }
107
+ for (const block of content) {
108
+ if (block.type === "tool_use" && str(block.id)) items.set(str(block.id), toolItem(block, move, cwd));
109
+ const done = block.type === "tool_result" ? items.get(str(block.tool_use_id)) : undefined;
110
+ if (done) done.status = block.is_error ? "failed" : "completed";
111
+ }
112
+ }
113
+ return { items: [...items.values()].slice(-MAX_ITEMS), finished };
114
+ }
115
+
116
+ /** The newest subagents of a Claude session, with their tool calls. */
117
+ export async function subagentLogs(sessionId: string, cwd: string): Promise<SubagentLog[]> {
118
+ const folder = await subagentFolder(sessionId, cwd);
119
+ if (!folder) return [];
120
+ const names = (await readdir(folder).catch(() => [])).filter((name) => /^agent-[A-Za-z0-9]+\.jsonl$/.test(name));
121
+ const dated = await Promise.all(
122
+ names.map(async (name) => ({ name, at: (await stat(path.join(folder, name)).catch(() => null))?.mtimeMs ?? 0 })),
123
+ );
124
+ const newest = dated.sort((a, b) => b.at - a.at).slice(0, MAX_RUNS);
125
+ const runs: SubagentLog[] = [];
126
+ for (const { name, at } of newest) {
127
+ const id = name.slice("agent-".length, -".jsonl".length);
128
+ const meta = JSON.parse(
129
+ (await readFile(path.join(folder, `agent-${id}.meta.json`), "utf8").catch(() => "{}")) || "{}",
130
+ ) as Block;
131
+ const worktree = str(meta.worktreePath).replace(/\/+$/, "");
132
+ // A subagent in its own worktree sees the same repository, so its paths map onto the agent's.
133
+ const move = (value: string) => (worktree ? value.split(worktree).join(cwd) : value);
134
+ const { items, finished } = transcriptItems(await tail(path.join(folder, name)).catch(() => ""), move, cwd);
135
+ runs.push({
136
+ id,
137
+ type: str(meta.agentType) || "Subagent",
138
+ description: str(meta.description),
139
+ toolUseId: str(meta.toolUseId) || null,
140
+ updatedAt: Math.round(at),
141
+ finished,
142
+ items,
143
+ });
144
+ }
145
+ return runs;
146
+ }
@@ -87,6 +87,10 @@ function lineCount(value: string | null): number {
87
87
  }
88
88
 
89
89
  function diffCounts(detail: Record<string, unknown>): { added: number; removed: number } {
90
+ // Subagent transcripts send counts, not the text.
91
+ if (typeof detail.added === "number") {
92
+ return { added: detail.added, removed: typeof detail.removed === "number" ? detail.removed : 0 };
93
+ }
90
94
  const diff = text(detail.unifiedDiff);
91
95
  if (diff) {
92
96
  let added = 0;
@@ -228,7 +232,7 @@ export function deriveActivity(items: readonly TimelineItemLike[], cwd: string):
228
232
  if (path) touch(path, "read", order).reads += 1;
229
233
  } else if ((detail.type === "edit" || detail.type === "write") && raw) {
230
234
  const write = detail.type === "write";
231
- const counts = write
235
+ const counts = write && typeof detail.added !== "number"
232
236
  ? { added: lineCount(text(detail.content)), removed: 0 }
233
237
  : diffCounts(detail);
234
238
  event.kind = write ? "write" : "edit";
@@ -0,0 +1,68 @@
1
+ import { defineRpc } from "@getpaseo/plugin";
2
+ import { z } from "zod";
3
+ import { GuidanceSchema } from "./settings";
4
+
5
+ // Claude Code hooks for an agent started from an issue. The daemon writes them as a Claude Code
6
+ // plugin and the launch passes its folder with --plugin-dir, so they never replace the settings
7
+ // Paseo itself passes with --settings. Each hook only prints text; none of them runs project code.
8
+
9
+ export const IDENTIFIER = /^[A-Za-z][A-Za-z0-9]*-\d+$/;
10
+
11
+ const HookIssueSchema = z.object({
12
+ identifier: z.string().regex(IDENTIFIER),
13
+ title: z.string(),
14
+ children: z.array(z.object({ identifier: z.string().regex(IDENTIFIER), title: z.string() })).max(100),
15
+ });
16
+ export type HookIssue = z.infer<typeof HookIssueSchema>;
17
+
18
+ export const agentHooksRpc = defineRpc({
19
+ name: "linear.agent.hooks",
20
+ input: z.object({ issue: HookIssueSchema, guidance: GuidanceSchema }),
21
+ /** Null when no hook applies or the daemon host cannot run them. */
22
+ output: z.object({ pluginDir: z.string().nullable() }),
23
+ });
24
+
25
+ /** Single-quotes text for a POSIX shell. */
26
+ export function shellQuote(text: string): string {
27
+ return `'${text.replace(/'/g, `'\\''`)}'`;
28
+ }
29
+
30
+ function printHook(event: string, context: string) {
31
+ const output = { hookSpecificOutput: { hookEventName: event, additionalContext: context } };
32
+ return { type: "command", command: `printf '%s' ${shellQuote(JSON.stringify(output))}` };
33
+ }
34
+
35
+ function issueLines(issue: HookIssue): string[] {
36
+ const lines = [`Linear issue ${issue.identifier}: ${issue.title}`];
37
+ for (const child of issue.children.slice(0, 30)) lines.push(`- ${child.identifier}: ${child.title}`);
38
+ return lines;
39
+ }
40
+
41
+ /** The plugin's hooks.json, or null when no guidance option needs a hook. */
42
+ export function claudeHooks(issue: HookIssue, guidance: z.infer<typeof GuidanceSchema>) {
43
+ const hooks: Record<string, unknown[]> = {};
44
+ if (guidance.subagentKeys) {
45
+ const context = [
46
+ `You are a subagent for ${issue.identifier}.`,
47
+ ...issueLines(issue),
48
+ "Put the key of the issue you work on in the first line of your reply, such as",
49
+ `"${issue.children[0]?.identifier ?? issue.identifier}: checked the form".`,
50
+ "If your work moves to another issue, state the new key.",
51
+ ].join("\n");
52
+ hooks.SubagentStart = [{ hooks: [printHook("SubagentStart", context)] }];
53
+ }
54
+ if (guidance.updateLinear || guidance.subagentKeys || guidance.paseoSubagents) {
55
+ // After compaction the summary can drop the issue, so restate it.
56
+ const context = [
57
+ `You work on ${issue.identifier}. The Linear rules in your first prompt still apply.`,
58
+ ...issueLines(issue),
59
+ ].join("\n");
60
+ hooks.SessionStart = [{ matcher: "compact", hooks: [printHook("SessionStart", context)] }];
61
+ }
62
+ return Object.keys(hooks).length > 0 ? { hooks } : null;
63
+ }
64
+
65
+ /** Claude Code plugin names are lowercase words joined by hyphens. */
66
+ export function hookPluginName(identifier: string): string {
67
+ return `paseo-linear-${identifier.toLowerCase()}`;
68
+ }
@@ -0,0 +1,130 @@
1
+ import { defineRpc } from "@getpaseo/plugin";
2
+ import { z } from "zod";
3
+ import { IDENTIFIER } from "./agent-hooks";
4
+ import type { IssueDetail } from "./linear";
5
+
6
+ // The plugin's MCP server for agents started from an issue. It gives them Linear tools for
7
+ // their issue tree and a way to start Paseo agents on its sub-issues. The daemon hosts it on
8
+ // 127.0.0.1, and each agent gets its own bearer token.
9
+
10
+ export const TOOLS_SERVER = "linear";
11
+ /** Children get only the Linear tools, so agents never start agents of their own. */
12
+ export const LINEAR_TOOLS = ["read_issue", "edit_issue"] as const;
13
+ export const AGENT_TOOLS = ["start_agent", "wait_agent"] as const;
14
+
15
+ export const McpHttpServerSchema = z.object({
16
+ type: z.literal("http"),
17
+ url: z.string().min(1),
18
+ headers: z.record(z.string(), z.string()).optional(),
19
+ });
20
+
21
+ export const ToolPolicySchema = z.object({
22
+ preapproved: z.array(z.object({ kind: z.literal("mcp"), server: z.string(), tool: z.string() })),
23
+ });
24
+
25
+ export const ToolScopeSchema = z.object({
26
+ /** The issue the launch started from. The tools reach it and its sub-issues. */
27
+ root: z.string().regex(IDENTIFIER),
28
+ /** The issue this agent works on, the default for read_issue and edit_issue. */
29
+ home: z.string().regex(IDENTIFIER),
30
+ /** The Paseo project whose Linear key loaded the issue. */
31
+ keyScope: z.string().nullable(),
32
+ workspaceId: z.string().min(1),
33
+ /** The agent config that started agents copy. */
34
+ config: z.object({
35
+ provider: z.string().min(1),
36
+ thinkingOptionId: z.string().optional(),
37
+ modeId: z.string().optional(),
38
+ }),
39
+ });
40
+ export type ToolScope = z.infer<typeof ToolScopeSchema>;
41
+
42
+ export const agentToolsRpc = defineRpc({
43
+ name: "linear.agent.tools",
44
+ input: ToolScopeSchema,
45
+ output: z.object({
46
+ /** Goes in the agent's labels, so a tool call can find the agent that made it. */
47
+ grantId: z.string(),
48
+ server: McpHttpServerSchema,
49
+ toolPolicy: ToolPolicySchema,
50
+ }),
51
+ });
52
+
53
+ export function toolPolicy(tools: readonly string[]): z.infer<typeof ToolPolicySchema> {
54
+ return { preapproved: tools.map((tool) => ({ kind: "mcp" as const, server: TOOLS_SERVER, tool })) };
55
+ }
56
+
57
+ const IssueKey = z.string().regex(IDENTIFIER);
58
+
59
+ export const ReadIssueInput = z.object({
60
+ issue: IssueKey.optional().describe("Issue key, such as ENG-12. The default is the issue you work on."),
61
+ });
62
+
63
+ export const EditIssueInput = z.object({
64
+ issue: IssueKey.optional().describe("Issue key, such as ENG-12. The default is the issue you work on."),
65
+ old_text: z
66
+ .string()
67
+ .describe("Exact text of the description to replace. It must occur one time. Use an empty string to add new_text at the end."),
68
+ new_text: z.string().describe("The text that replaces old_text."),
69
+ });
70
+
71
+ export const StartAgentInput = z.object({
72
+ issue: IssueKey.describe("Key of the sub-issue the agent works on."),
73
+ title: z.string().min(1).max(120).describe("Short title. Start it with the issue key."),
74
+ prompt: z.string().min(1).describe("The full task. The agent does not see your conversation."),
75
+ thinking: z
76
+ .string()
77
+ .optional()
78
+ .describe("Thinking option of the agent's model, such as low, medium, or high. The default is yours."),
79
+ });
80
+
81
+ export const WaitAgentInput = z.object({
82
+ agent_id: z.string().min(1),
83
+ timeout_seconds: z.number().int().min(1).max(600).default(50),
84
+ });
85
+
86
+ /** Replaces the one place where old text occurs. Empty old text adds the new text at the end. */
87
+ export function editText(body: string, oldText: string, newText: string): { body: string } | { error: string } {
88
+ if (oldText === "") return { body: body.trimEnd() ? `${body.trimEnd()}\n\n${newText}` : newText };
89
+ const first = body.indexOf(oldText);
90
+ if (first < 0) return { error: "old_text is not in the description. Read the issue again, because it can change." };
91
+ if (body.indexOf(oldText, first + 1) >= 0) {
92
+ return { error: "old_text occurs more than one time. Add nearby lines so that it occurs one time." };
93
+ }
94
+ return { body: body.slice(0, first) + newText + body.slice(first + oldText.length) };
95
+ }
96
+
97
+ /** Counts Markdown task list items, so a reply can say how many are done. */
98
+ export function taskCount(body: string): { done: number; total: number } {
99
+ const items = [...body.matchAll(/^\s*[-*+] \[([ xX])\]/gm)];
100
+ return { done: items.filter((item) => item[1] !== " ").length, total: items.length };
101
+ }
102
+
103
+ /** The issue as text for read_issue. */
104
+ export function issueText(issue: IssueDetail): string {
105
+ const tasks = taskCount(issue.description ?? "");
106
+ const lines = [
107
+ `${issue.identifier}: ${issue.title}`,
108
+ `Status: ${issue.state.name}`,
109
+ `URL: ${issue.url}`,
110
+ tasks.total > 0 ? `Task list: ${tasks.done} of ${tasks.total} checked` : null,
111
+ "",
112
+ "Description:",
113
+ issue.description?.trim() || "(empty)",
114
+ ];
115
+ if (issue.children.length > 0) {
116
+ lines.push("", "Sub-issues:");
117
+ for (const child of issue.children) lines.push(`- ${child.identifier} [${child.state.name}]: ${child.title}`);
118
+ }
119
+ return lines.filter((line) => line !== null).join("\n");
120
+ }
121
+
122
+ /** Opens the prompt of an agent that start_agent makes. */
123
+ export function childBrief(key: string, root: string): string {
124
+ const parent = key.toUpperCase() === root.toUpperCase() ? "" : `, a sub-issue of ${root}`;
125
+ return [
126
+ `You work on Linear issue ${key}${parent}. Start your first reply with "${key}:".`,
127
+ `Use the ${TOOLS_SERVER} MCP tools: read_issue shows the issue, and edit_issue changes its description.`,
128
+ "Check off each task list item you finish by replacing `- [ ]` with `- [x]`. Do not post comments.",
129
+ ].join("\n");
130
+ }
@@ -0,0 +1,148 @@
1
+ import { z } from "zod";
2
+
3
+ // The user's own plugin icon. The web app draws it as an image, which never runs scripts or
4
+ // loads files; the checks below also refuse such content. Paint recolors every visible pixel,
5
+ // and Solid fills shapes that only have an outline.
6
+
7
+ export const ICON_PAINTS = ["original", "theme", "accent", "linear", "custom"] as const;
8
+ export type IconPaint = (typeof ICON_PAINTS)[number];
9
+
10
+ /** Linear's brand indigo, the plugin's own color. */
11
+ export const LINEAR_INDIGO = "#5E6AD2";
12
+ export const MAX_SVG_LENGTH = 64_000;
13
+ export const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
14
+
15
+ export const IconSettingsSchema = z.object({
16
+ /** Checked SVG markup. Empty uses the built-in icon. */
17
+ svg: z.string().max(MAX_SVG_LENGTH).default(""),
18
+ paint: z.enum(ICON_PAINTS).default("original"),
19
+ /** The color of the custom paint. */
20
+ color: z.string().regex(HEX_COLOR).default(LINEAR_INDIGO),
21
+ /** Fill shapes that only have an outline. */
22
+ solid: z.boolean().default(false),
23
+ });
24
+ export type IconSettings = z.infer<typeof IconSettingsSchema>;
25
+
26
+ const BLOCKED: readonly (readonly [RegExp, string])[] = [
27
+ [/<!DOCTYPE|<!ENTITY/i, "The SVG has a DOCTYPE or entities. Export it again without them."],
28
+ [/<script[\s>/]/i, "The SVG has a script. Use an SVG with shapes only."],
29
+ [/<foreignObject[\s>/]/i, "The SVG has HTML content. Use an SVG with shapes only."],
30
+ [/<[^>]*\son[a-z]+\s*=/i, "The SVG has event handlers. Use an SVG with shapes only."],
31
+ [/[\s:]href\s*=\s*["']\s*(?!#)/i, "The SVG links to other files or images. Use an SVG that has all its shapes inside."],
32
+ [/url\(\s*["']?\s*(?!#)/i, "The SVG uses files outside it. Use an SVG that has all its shapes inside."],
33
+ [/@import/i, "The SVG imports styles from another file."],
34
+ ];
35
+
36
+ const ROOT_TAG = /^<svg\b[^>]*>/i;
37
+ const NUMBER = String.raw`(-?[\d.]+(?:e[-+]?\d+)?)`;
38
+ const VIEW_BOX = new RegExp(String.raw`\sviewBox\s*=\s*["']\s*${NUMBER}[\s,]+${NUMBER}[\s,]+${NUMBER}[\s,]+${NUMBER}\s*["']`, "i");
39
+
40
+ const attribute = (tag: string, name: string) =>
41
+ new RegExp(String.raw`\s${name}\s*=\s*["']([^"']*)["']`, "i").exec(tag)?.[1]?.trim() ?? null;
42
+ const withoutAttribute = (tag: string, name: string) =>
43
+ tag.replace(new RegExp(String.raw`\s${name}\s*=\s*(?:"[^"]*"|'[^']*')`, "gi"), "");
44
+
45
+ /** The root's viewBox as x, y, width, and height, or null without a usable one. */
46
+ export function viewBoxOf(svg: string): [number, number, number, number] | null {
47
+ const root = ROOT_TAG.exec(svg)?.[0] ?? "";
48
+ const box = VIEW_BOX.exec(root)?.slice(1, 5).map(Number);
49
+ if (!box || box.some((value) => !Number.isFinite(value))) return null;
50
+ const [x = 0, y = 0, width = 0, height = 0] = box;
51
+ return width > 0 && height > 0 ? [x, y, width, height] : null;
52
+ }
53
+
54
+ /** Checks pasted or uploaded SVG and returns it ready to save, or why it cannot be used. */
55
+ export function parseIconSvg(text: string): { svg: string } | { error: string } {
56
+ const svg = text
57
+ .replace(/^\uFEFF/, "")
58
+ .replace(/<\?[\s\S]*?\?>/g, "")
59
+ .replace(/<!--[\s\S]*?-->/g, "")
60
+ .trim();
61
+ if (!svg) return { error: "Paste SVG markup or choose an SVG file." };
62
+ if (svg.length > MAX_SVG_LENGTH) {
63
+ return { error: `The SVG is too large. Use one under ${Math.round(MAX_SVG_LENGTH / 1000)} KB.` };
64
+ }
65
+ if (!/^<svg[\s>]/i.test(svg) || !/<\/svg>$/i.test(svg)) {
66
+ return { error: "This is not an SVG. It must start with <svg and end with </svg>." };
67
+ }
68
+ for (const [pattern, error] of BLOCKED) if (pattern.test(svg)) return { error };
69
+ const root = ROOT_TAG.exec(svg)?.[0] ?? "";
70
+ let next = root;
71
+ if (!viewBoxOf(svg)) {
72
+ const width = Number(/^([\d.]+)(?:px)?$/.exec(attribute(root, "width") ?? "")?.[1]);
73
+ const height = Number(/^([\d.]+)(?:px)?$/.exec(attribute(root, "height") ?? "")?.[1]);
74
+ if (!(width > 0 && height > 0)) return { error: "The SVG needs a viewBox, or a width and height in pixels." };
75
+ next = withoutAttribute(next, "viewBox").replace(/^<svg/i, `<svg viewBox="0 0 ${width} ${height}"`);
76
+ }
77
+ // The image scales to its box from the viewBox, so a fixed size would only crop it.
78
+ next = withoutAttribute(withoutAttribute(next, "width"), "height");
79
+ if (!/\sxmlns\s*=/.test(next)) next = next.replace(/^<svg/i, '<svg xmlns="http://www.w3.org/2000/svg"');
80
+ return { svg: next + svg.slice(root.length) };
81
+ }
82
+
83
+ /** Keeps a color inside an attribute value; anything else becomes gray. */
84
+ export function safeColor(color: string): string {
85
+ return /^[#a-z0-9(),.%\s-]+$/i.test(color) ? color.trim() : "#888888";
86
+ }
87
+
88
+ const FILL_NONE = /(?<![\w-])(fill\s*:\s*)none\b/gi;
89
+
90
+ /** Fills shapes that only have an outline, with their stroke color or the icon color. */
91
+ export function fillOutlines(svg: string): string {
92
+ const tags = svg.replace(/<([a-z][\w:.-]*)(\s[^>]*?)?(\/?)>/gi, (tag, name: string, attrs: string | undefined, close: string) => {
93
+ if (!attrs) return tag;
94
+ const stroke = attribute(attrs, "stroke") ?? /(?<![\w-])stroke\s*:\s*([^;"']+)/i.exec(attrs)?.[1]?.trim();
95
+ const fill = stroke && stroke !== "none" ? stroke : "currentColor";
96
+ const next = attrs.replace(/(\sfill\s*=\s*["'])\s*none\s*(["'])/gi, `$1${fill}$2`).replace(FILL_NONE, `$1${fill}`);
97
+ return `<${name}${next}${close}>`;
98
+ });
99
+ return tags.replace(/(<style\b[^>]*>)([\s\S]*?)(<\/style>)/gi, (_all, open: string, css: string, end: string) =>
100
+ `${open}${css.replace(FILL_NONE, "$1currentColor")}${end}`,
101
+ );
102
+ }
103
+
104
+ const TINT_ID = "paseo-linear-tint";
105
+
106
+ /** Paints every visible pixel one color and keeps its transparency. */
107
+ export function tint(svg: string, color: string): string {
108
+ const [x, y, width, height] = viewBoxOf(svg) ?? [0, 0, 24, 24];
109
+ const filter =
110
+ `<filter id="${TINT_ID}" filterUnits="userSpaceOnUse" x="${x - width}" y="${y - height}" width="${width * 3}" height="${height * 3}" color-interpolation-filters="sRGB">` +
111
+ `<feFlood flood-color="${safeColor(color)}"/><feComposite in2="SourceAlpha" operator="in"/></filter>`;
112
+ return svg
113
+ .replace(ROOT_TAG, (root) => `${root}<defs>${filter}</defs><g filter="url(#${TINT_ID})">`)
114
+ .replace(/<\/svg>$/i, "</g></svg>");
115
+ }
116
+
117
+ export interface IconLook {
118
+ /** The color `currentColor` takes: the color Paseo gives the icon. */
119
+ ink: string;
120
+ /** One color for the whole icon, or null to keep its colors. */
121
+ paint: string | null;
122
+ solid: boolean;
123
+ }
124
+
125
+ /** The SVG to draw for saved markup, or null when it no longer passes the checks. */
126
+ export function iconMarkup(saved: string, look: IconLook): string | null {
127
+ const parsed = parseIconSvg(saved);
128
+ if ("error" in parsed) return null;
129
+ const filled = look.solid ? fillOutlines(parsed.svg) : parsed.svg;
130
+ const inked = filled.replace(/currentColor/gi, safeColor(look.ink));
131
+ return look.paint ? tint(inked, look.paint) : inked;
132
+ }
133
+
134
+ /** The one color a paint gives, or null for the icon's own colors. */
135
+ export function paintColor(icon: IconSettings, colors: { ink: string; accent: string }): string | null {
136
+ switch (icon.paint) {
137
+ case "original":
138
+ return null;
139
+ case "theme":
140
+ return colors.ink;
141
+ case "accent":
142
+ return colors.accent;
143
+ case "linear":
144
+ return LINEAR_INDIGO;
145
+ case "custom":
146
+ return icon.color;
147
+ }
148
+ }
@@ -0,0 +1,93 @@
1
+ import type { Point } from "./graph-geometry";
2
+ import type { Graph } from "./graph-model";
3
+
4
+ // The graph camera shifts and zooms the canvas, which scales around its center. A canvas
5
+ // point p draws at center + shift + zoom * (p - center). Zoom 1 with no shift shows it all.
6
+
7
+ export interface Camera {
8
+ x: number;
9
+ y: number;
10
+ zoom: number;
11
+ }
12
+
13
+ export interface Box {
14
+ left: number;
15
+ top: number;
16
+ right: number;
17
+ bottom: number;
18
+ }
19
+
20
+ export const FIT: Camera = { x: 0, y: 0, zoom: 1 };
21
+ export const MIN_ZOOM = 1;
22
+ export const MAX_ZOOM = 4;
23
+ /** Auto mode zooms no further than this, so the nodes around the agents stay in view. */
24
+ const AUTO_ZOOM = 2.5;
25
+ const PAD_X = 90;
26
+ const PAD_Y = 56;
27
+
28
+ const clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));
29
+
30
+ /** Where a canvas point draws in the view. */
31
+ export function project(camera: Camera, at: Point, width: number, height: number): Point {
32
+ return {
33
+ x: width / 2 + camera.x + camera.zoom * (at.x - width / 2),
34
+ y: height / 2 + camera.y + camera.zoom * (at.y - height / 2),
35
+ };
36
+ }
37
+
38
+ /** The canvas point that draws at a point of the view. */
39
+ export function unproject(camera: Camera, at: Point, width: number, height: number): Point {
40
+ return {
41
+ x: width / 2 + (at.x - width / 2 - camera.x) / camera.zoom,
42
+ y: height / 2 + (at.y - height / 2 - camera.y) / camera.zoom,
43
+ };
44
+ }
45
+
46
+ /** Keeps the zoom in range and the canvas edges at or past the edges of the view. */
47
+ export function clampCamera(camera: Camera, width: number, height: number): Camera {
48
+ const zoom = clamp(camera.zoom, MIN_ZOOM, MAX_ZOOM);
49
+ const spanX = ((zoom - 1) * width) / 2;
50
+ const spanY = ((zoom - 1) * height) / 2;
51
+ return { zoom, x: clamp(camera.x, -spanX, spanX), y: clamp(camera.y, -spanY, spanY) };
52
+ }
53
+
54
+ /** Zooms by a factor and keeps the canvas point under `at` where it is. */
55
+ export function zoomAt(camera: Camera, at: Point, factor: number, width: number, height: number): Camera {
56
+ const zoom = clamp(camera.zoom * factor, MIN_ZOOM, MAX_ZOOM);
57
+ const cx = width / 2;
58
+ const cy = height / 2;
59
+ const px = cx + (at.x - cx - camera.x) / camera.zoom;
60
+ const py = cy + (at.y - cy - camera.y) / camera.zoom;
61
+ return clampCamera({ zoom, x: at.x - cx - zoom * (px - cx), y: at.y - cy - zoom * (py - cy) }, width, height);
62
+ }
63
+
64
+ /** Shows the box as large as fits in the view, up to the auto zoom. */
65
+ export function frameBox(box: Box, width: number, height: number): Camera {
66
+ const fit = Math.min(width / Math.max(1, box.right - box.left), height / Math.max(1, box.bottom - box.top));
67
+ const zoom = clamp(fit, MIN_ZOOM, AUTO_ZOOM);
68
+ const x = zoom * (width / 2 - (box.left + box.right) / 2);
69
+ const y = zoom * (height / 2 - (box.top + box.bottom) / 2);
70
+ return clampCamera({ zoom, x, y }, width, height);
71
+ }
72
+
73
+ // The area auto mode shows: the nodes the running agents work on and their neighbors, with
74
+ // room for labels and cursors. Null when no agent works on a node other than the issue.
75
+ export function focusBox(graph: Graph, positions: ReadonlyMap<string, Point>, targets: Iterable<string>): Box | null {
76
+ const ids = new Set<string>();
77
+ for (const target of targets) if (target !== graph.root) ids.add(target);
78
+ if (ids.size === 0) return null;
79
+ for (const edge of graph.edges) {
80
+ if (ids.has(edge.from) && edge.to !== graph.root) ids.add(edge.to);
81
+ else if (ids.has(edge.to) && edge.from !== graph.root) ids.add(edge.from);
82
+ }
83
+ let box: Box | null = null;
84
+ for (const id of ids) {
85
+ const at = positions.get(id);
86
+ if (!at) continue;
87
+ box = box
88
+ ? { left: Math.min(box.left, at.x), top: Math.min(box.top, at.y), right: Math.max(box.right, at.x), bottom: Math.max(box.bottom, at.y) }
89
+ : { left: at.x, top: at.y, right: at.x, bottom: at.y };
90
+ }
91
+ if (!box) return null;
92
+ return { left: box.left - PAD_X, top: box.top - PAD_Y, right: box.right + PAD_X, bottom: box.bottom + PAD_Y };
93
+ }
package/shared/live.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { defineRpc } from "@getpaseo/plugin";
2
2
  import { z } from "zod";
3
+ import { McpHttpServerSchema, ToolPolicySchema } from "./agent-tools";
3
4
  import { IssueCardSchema, WorkflowStateSchema } from "./linear";
4
5
 
5
6
  // Contracts for Linear Live, todo sync, and project setup. Agents the plugin starts for itself
@@ -42,6 +43,8 @@ export const InitProposalSchema = z.object({
42
43
  });
43
44
  export type InitProposal = z.infer<typeof InitProposalSchema>;
44
45
 
46
+ /** Files an explore run may list; the map and the graph show this many and more. */
47
+ export const MAX_EXPLORE_FILES = 150;
45
48
  export const MAX_LIST_DIRS = 40;
46
49
  export const MAX_DIR_ENTRIES = 60;
47
50
 
@@ -64,6 +67,41 @@ export const linksRpc = defineRpc({
64
67
  }),
65
68
  });
66
69
 
70
+ const SubagentLogSchema = z.object({
71
+ id: z.string(),
72
+ type: z.string(),
73
+ description: z.string(),
74
+ /** The parent's call that started it, when Claude recorded one. */
75
+ toolUseId: z.string().nullable(),
76
+ updatedAt: z.number(),
77
+ /** Its last reply ended the run. */
78
+ finished: z.boolean(),
79
+ items: z.array(
80
+ z.object({
81
+ type: z.literal("tool_call"),
82
+ callId: z.string(),
83
+ name: z.string(),
84
+ status: z.enum(["running", "completed", "failed"]),
85
+ detail: z.record(z.string(), z.unknown()),
86
+ }),
87
+ ),
88
+ });
89
+ export type SubagentLog = z.infer<typeof SubagentLogSchema>;
90
+
91
+ /** Tool calls of the subagents an agent started inside its provider. */
92
+ export const subagentLogsRpc = defineRpc({
93
+ name: "linear.live.subagents",
94
+ input: z.object({ agentId: z.string().min(1) }),
95
+ output: z.object({ runs: z.array(SubagentLogSchema) }),
96
+ });
97
+
98
+ /** Forgets the saved explore map of the agent's issue, so the map falls back to ticket text. */
99
+ export const clearMapRpc = defineRpc({
100
+ name: "linear.live.map.clear",
101
+ input: z.object({ agentId: z.string().min(1) }),
102
+ output: z.object({ cleared: z.boolean() }),
103
+ });
104
+
67
105
  export const liveMapRpc = defineRpc({
68
106
  name: "linear.live.map",
69
107
  input: z.object({ identifier: z.string().min(1) }),
@@ -80,6 +118,11 @@ export const LaunchConfigSchema = z.object({
80
118
  provider: z.string().min(1),
81
119
  thinkingOptionId: z.string().optional(),
82
120
  modeId: z.string().optional(),
121
+ /** Only the folder of the issue's Claude Code hooks; see agent-hooks.ts. */
122
+ providerOptions: z.object({ extraArgs: z.object({ "plugin-dir": z.string().min(1) }) }).optional(),
123
+ /** Only the plugin's own MCP server; see agent-tools.ts. */
124
+ mcpServers: z.object({ linear: McpHttpServerSchema }).optional(),
125
+ toolPolicy: ToolPolicySchema.optional(),
83
126
  });
84
127
 
85
128
  // The daemon maps the files first and then starts the agent, so closing the app mid-way