@yannelli/paseo-linear-plugin 1.1.0-beta.0 → 1.1.0-beta.1
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.
- package/OVERVIEW.md +3 -3
- package/README.md +26 -10
- package/client/graph-camera.tsx +219 -0
- package/client/graph-labels.ts +14 -4
- package/client/launch-guidance.tsx +86 -0
- package/client/launch-plan.ts +50 -2
- package/client/launch.tsx +10 -1
- package/client/live-agents.tsx +138 -23
- package/client/live-cursor.tsx +3 -1
- package/client/live-graph.tsx +159 -75
- package/client/live-header.tsx +29 -0
- package/client/live-panel.tsx +29 -8
- package/client/live-timeline.ts +38 -3
- package/client/settings-live.tsx +25 -1
- package/client/tool-buttons.tsx +68 -0
- package/client/web.ts +45 -0
- package/index.server.ts +5 -1
- package/package.json +1 -1
- package/server/agent-hooks.ts +37 -0
- package/server/agent-runs.ts +15 -2
- package/server/agent-tools.ts +240 -0
- package/server/init.ts +1 -0
- package/server/live.ts +27 -1
- package/server/mcp-http.ts +152 -0
- package/server/stores.ts +8 -1
- package/server/subagent-logs.ts +146 -0
- package/shared/activity.ts +5 -1
- package/shared/agent-hooks.ts +68 -0
- package/shared/agent-tools.ts +130 -0
- package/shared/graph-camera.ts +93 -0
- package/shared/live.ts +43 -0
- package/shared/prompts.ts +49 -1
- package/shared/settings.ts +31 -0
package/shared/activity.ts
CHANGED
|
@@ -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,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
|
package/shared/prompts.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { TOOLS_SERVER } from "./agent-tools";
|
|
1
2
|
import type { IssueDetail } from "./linear";
|
|
2
|
-
import type { AgentAction, LinearSettings, ProjectConfig } from "./settings";
|
|
3
|
+
import type { AgentAction, Guidance, LinearSettings, ProjectConfig } from "./settings";
|
|
3
4
|
import { todoConventionLine } from "./todo-sync";
|
|
4
5
|
|
|
5
6
|
export const DEFAULT_TEMPLATES: Record<AgentAction, string> = {
|
|
@@ -112,6 +113,47 @@ export interface ComposePromptInput {
|
|
|
112
113
|
project: ProjectConfig | null;
|
|
113
114
|
includeComments: boolean;
|
|
114
115
|
extraInstructions: string;
|
|
116
|
+
/** Launch options that add instructions; the project's saved ones when omitted. */
|
|
117
|
+
guidance?: Guidance;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The instructions each guidance option adds, in prompt order. */
|
|
121
|
+
export function guidanceSections(issue: IssueDetail, guidance: Guidance): string[] {
|
|
122
|
+
const sections: string[] = [];
|
|
123
|
+
const example = issue.children[0]?.identifier ?? issue.identifier;
|
|
124
|
+
if (guidance.updateLinear) {
|
|
125
|
+
sections.push(
|
|
126
|
+
[
|
|
127
|
+
"Keep Linear current while you work:",
|
|
128
|
+
`- Start each todo with the issue key it is for, such as "${example}: add the form".`,
|
|
129
|
+
"- Mark a todo in progress when you start it and completed when it is done. Linear Live moves the issue from these todos.",
|
|
130
|
+
`- Use the ${TOOLS_SERVER} MCP tools: read_issue shows an issue, and edit_issue changes its description.`,
|
|
131
|
+
"- When you finish a task list item in a description, check it off: replace `- [ ] item` with `- [x] item`.",
|
|
132
|
+
"- When the plan changes, update the description. Do not post comments.",
|
|
133
|
+
].join("\n"),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
if (guidance.subagentKeys) {
|
|
137
|
+
sections.push(
|
|
138
|
+
[
|
|
139
|
+
"When you start a subagent:",
|
|
140
|
+
`- Begin its description and its prompt with the Linear issue key it works on, such as "${example}: review the form".`,
|
|
141
|
+
"- Tell it to put that key in the first line of its reply, and to state the new key if its work moves to another issue.",
|
|
142
|
+
].join("\n"),
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
if (guidance.paseoSubagents) {
|
|
146
|
+
sections.push(
|
|
147
|
+
[
|
|
148
|
+
"Hand off work to Paseo agents, not to your built-in subagent tool:",
|
|
149
|
+
`- Start each one with the ${TOOLS_SERVER} start_agent tool, one agent for each sub-issue. It runs on your provider and model.`,
|
|
150
|
+
"- Set its thinking to fit the task: low for search and reading, medium for routine edits, high for design, debugging, and review.",
|
|
151
|
+
`- Start its title with the issue key, such as "${example}: write the tests". Give the full task in its prompt, because it does not see your conversation.`,
|
|
152
|
+
"- Call wait_agent for each agent and read its result before you continue.",
|
|
153
|
+
].join("\n"),
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
return sections;
|
|
115
157
|
}
|
|
116
158
|
|
|
117
159
|
export function composePrompt(input: ComposePromptInput): string {
|
|
@@ -139,6 +181,8 @@ export function composePrompt(input: ComposePromptInput): string {
|
|
|
139
181
|
`Project steps:\n${steps.map((step, index) => `${index + 1}. ${step}`).join("\n")}`,
|
|
140
182
|
);
|
|
141
183
|
}
|
|
184
|
+
const guidance = input.guidance ?? project?.guidance;
|
|
185
|
+
if (guidance) sections.push(...guidanceSections(issue, guidance));
|
|
142
186
|
const extra = input.extraInstructions.trim();
|
|
143
187
|
if (extra) sections.push(`Additional instructions:\n${extra}`);
|
|
144
188
|
return sections.join("\n\n");
|
|
@@ -157,6 +201,10 @@ export const AGENT_LABELS = {
|
|
|
157
201
|
project: "linear.project",
|
|
158
202
|
explore: "linear.explore",
|
|
159
203
|
init: "linear.init",
|
|
204
|
+
/** The grant of the agent's Linear MCP tools, so a tool call can find its agent. */
|
|
205
|
+
tools: "linear.tools",
|
|
206
|
+
/** The grant whose start_agent call made this agent. */
|
|
207
|
+
startedBy: "linear.started-by",
|
|
160
208
|
} as const;
|
|
161
209
|
|
|
162
210
|
/** Agents the plugin starts for itself: explore and project setup. */
|
package/shared/settings.ts
CHANGED
|
@@ -12,6 +12,18 @@ const ActionOverrideSchema = z.object({
|
|
|
12
12
|
text: z.string().default(""),
|
|
13
13
|
});
|
|
14
14
|
|
|
15
|
+
/** Extra instructions the launch adds to the prompt. Saved per project. */
|
|
16
|
+
export const GuidanceSchema = z.object({
|
|
17
|
+
/** Keep Linear current: todos with sub-issue keys, and comments when a Linear tool exists. */
|
|
18
|
+
updateLinear: z.boolean().default(false),
|
|
19
|
+
/** Subagents state the issue key they work on. Claude agents also get a hook that tells them. */
|
|
20
|
+
subagentKeys: z.boolean().default(false),
|
|
21
|
+
/** Hand work to Paseo agents on the same provider, with thinking that fits the task. */
|
|
22
|
+
paseoSubagents: z.boolean().default(false),
|
|
23
|
+
});
|
|
24
|
+
export type Guidance = z.infer<typeof GuidanceSchema>;
|
|
25
|
+
export const GUIDANCE_KEYS = ["updateLinear", "subagentKeys", "paseoSubagents"] as const;
|
|
26
|
+
|
|
15
27
|
export const ProjectConfigSchema = z.object({
|
|
16
28
|
projectId: z.string(),
|
|
17
29
|
displayName: z.string(),
|
|
@@ -21,9 +33,20 @@ export const ProjectConfigSchema = z.object({
|
|
|
21
33
|
steps: z.array(z.string()).default([]),
|
|
22
34
|
implement: ActionOverrideSchema.prefault({}),
|
|
23
35
|
review: ActionOverrideSchema.prefault({}),
|
|
36
|
+
guidance: GuidanceSchema.prefault({}),
|
|
24
37
|
});
|
|
25
38
|
export type ProjectConfig = z.infer<typeof ProjectConfigSchema>;
|
|
26
39
|
|
|
40
|
+
/** Settings with a project's guidance saved, adding the project when it has no entry yet. */
|
|
41
|
+
export function withProjectGuidance(
|
|
42
|
+
values: LinearSettings,
|
|
43
|
+
project: { projectId: string; displayName: string; rootPath: string },
|
|
44
|
+
guidance: Guidance,
|
|
45
|
+
): LinearSettings {
|
|
46
|
+
const known = values.projects.find((entry) => entry.projectId === project.projectId);
|
|
47
|
+
return { ...values, projects: upsertProject(values, { ...(known ?? emptyProjectConfig(project)), guidance }) };
|
|
48
|
+
}
|
|
49
|
+
|
|
27
50
|
export const ISOLATIONS = ["worktree", "workspace"] as const;
|
|
28
51
|
export type Isolation = (typeof ISOLATIONS)[number];
|
|
29
52
|
|
|
@@ -31,6 +54,9 @@ export const MAPPING_MODES = ["semantic", "explore"] as const;
|
|
|
31
54
|
export type MappingMode = (typeof MAPPING_MODES)[number];
|
|
32
55
|
|
|
33
56
|
export const LIVE_VIEWS = ["map", "graph"] as const;
|
|
57
|
+
/** Auto follows the running agents; fit shows the whole graph. */
|
|
58
|
+
export const GRAPH_CAMERAS = ["auto", "fit"] as const;
|
|
59
|
+
export type GraphCamera = (typeof GRAPH_CAMERAS)[number];
|
|
34
60
|
export type LiveView = (typeof LIVE_VIEWS)[number];
|
|
35
61
|
|
|
36
62
|
export const linearSettings = defineSettings({
|
|
@@ -57,9 +83,14 @@ export const linearSettings = defineSettings({
|
|
|
57
83
|
enabled: z.boolean().default(true),
|
|
58
84
|
mapping: z.enum(MAPPING_MODES).default("semantic"),
|
|
59
85
|
exploreProvider: z.string().default(""),
|
|
86
|
+
/** Thinking option of the explore model; empty is the model's default. */
|
|
87
|
+
exploreEffort: z.string().default(""),
|
|
60
88
|
syncTodos: z.boolean().default(false),
|
|
61
89
|
view: z.enum(LIVE_VIEWS).default("map"),
|
|
62
90
|
issuesCollapsed: z.boolean().default(false),
|
|
91
|
+
graphCamera: z.enum(GRAPH_CAMERAS).default("auto"),
|
|
92
|
+
/** The wheel and drags scroll the page instead of moving the graph. */
|
|
93
|
+
graphLocked: z.boolean().default(false),
|
|
63
94
|
})
|
|
64
95
|
.prefault({}),
|
|
65
96
|
// Where the plugin works: every project, minus or plus per-project switches.
|