@yannelli/paseo-linear-plugin 1.0.1 → 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 +20 -2
- package/README.md +91 -5
- package/client/access.tsx +66 -0
- package/client/browser.tsx +12 -9
- package/client/graph-camera.tsx +219 -0
- package/client/graph-labels.ts +147 -0
- package/client/launch-choices.ts +15 -4
- package/client/launch-guidance.tsx +86 -0
- package/client/launch-plan.ts +86 -16
- package/client/launch.tsx +19 -5
- package/client/live-agents.tsx +522 -0
- package/client/live-cursor.tsx +293 -0
- package/client/live-feed.tsx +151 -0
- package/client/live-graph.tsx +518 -0
- package/client/live-header.tsx +341 -0
- package/client/live-issues.tsx +345 -0
- package/client/live-map.tsx +345 -0
- package/client/live-panel.tsx +465 -0
- package/client/live-timeline.ts +376 -0
- package/client/pill.tsx +393 -0
- package/client/project-init.tsx +168 -0
- package/client/settings-access.tsx +56 -0
- package/client/settings-live.tsx +145 -0
- package/client/settings-projects.tsx +9 -0
- package/client/tool-buttons.tsx +68 -0
- package/client/web.ts +45 -0
- package/index.client.tsx +39 -1
- package/index.server.ts +29 -5
- package/package.json +1 -1
- package/server/agent-hooks.ts +37 -0
- package/server/agent-runs.ts +312 -0
- package/server/agent-tools.ts +240 -0
- package/server/handlers.ts +12 -1
- package/server/init.ts +65 -0
- package/server/links.ts +153 -0
- package/server/live.ts +360 -0
- package/server/mcp-http.ts +152 -0
- package/server/stores.ts +126 -0
- package/server/subagent-logs.ts +146 -0
- package/server/sync.ts +127 -0
- package/shared/activity.ts +312 -0
- package/shared/agent-hooks.ts +68 -0
- package/shared/agent-tools.ts +130 -0
- package/shared/graph-camera.ts +93 -0
- package/shared/graph-geometry.ts +122 -0
- package/shared/graph-model.ts +319 -0
- package/shared/links.ts +252 -0
- package/shared/live.ts +243 -0
- package/shared/map-model.ts +192 -0
- package/shared/project-setup.ts +55 -0
- package/shared/prompts.ts +65 -2
- package/shared/settings.ts +88 -1
- package/shared/shell-activity.ts +309 -0
- package/shared/shell-lexer.ts +180 -0
- package/shared/subagents.ts +106 -0
- package/shared/todo-sync.ts +158 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { displayCommand, shellActivity } from "./shell-activity";
|
|
2
|
+
import { isSubagentCall, mergeSubagent, type SubagentRun, subagentOf } from "./subagents";
|
|
3
|
+
import { type TodoLike, todoKey, todoStatus } from "./todo-sync";
|
|
4
|
+
|
|
5
|
+
// Structural view of Paseo timeline items, so shared code needs no protocol import.
|
|
6
|
+
export interface TimelineItemLike {
|
|
7
|
+
type: string;
|
|
8
|
+
[key: string]: unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type TouchKind = "read" | "edit" | "write" | "search";
|
|
12
|
+
export type EventKind = TouchKind | "list" | "shell" | "agent" | "tool";
|
|
13
|
+
export type ToolStatus = "running" | "completed" | "failed" | "canceled";
|
|
14
|
+
|
|
15
|
+
export interface FileTouch {
|
|
16
|
+
path: string;
|
|
17
|
+
reads: number;
|
|
18
|
+
edits: number;
|
|
19
|
+
added: number;
|
|
20
|
+
removed: number;
|
|
21
|
+
created: boolean;
|
|
22
|
+
/** Position of the last touch in the timeline. Higher is more recent. */
|
|
23
|
+
order: number;
|
|
24
|
+
last: TouchKind;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ActivityEvent {
|
|
28
|
+
id: string;
|
|
29
|
+
kind: EventKind;
|
|
30
|
+
text: string;
|
|
31
|
+
/** The command as run, for shell events. */
|
|
32
|
+
detail: string | null;
|
|
33
|
+
path: string | null;
|
|
34
|
+
/** Every file the event read or changed; `path` is the first. */
|
|
35
|
+
paths: string[];
|
|
36
|
+
status: ToolStatus;
|
|
37
|
+
added: number;
|
|
38
|
+
removed: number;
|
|
39
|
+
exitCode: number | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface DirTouch {
|
|
43
|
+
/** Folder with a trailing slash; "" is the repository root. */
|
|
44
|
+
path: string;
|
|
45
|
+
/** Searched recursively, not only listed. */
|
|
46
|
+
deep: boolean;
|
|
47
|
+
order: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** What the agent works on now: a file, or an issue whose todo it just started. */
|
|
51
|
+
export type AgentFocus = { kind: "file"; path: string } | { kind: "issue"; key: string };
|
|
52
|
+
|
|
53
|
+
export interface Activity {
|
|
54
|
+
focus: AgentFocus | null;
|
|
55
|
+
files: FileTouch[];
|
|
56
|
+
dirs: DirTouch[];
|
|
57
|
+
/** Subagents started inside the provider, in launch order. */
|
|
58
|
+
subagents: SubagentRun[];
|
|
59
|
+
/** Newest first. */
|
|
60
|
+
events: ActivityEvent[];
|
|
61
|
+
current: ActivityEvent | null;
|
|
62
|
+
todos: TodoLike[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const MAX_EVENTS = 40;
|
|
66
|
+
// Bookkeeping tools: the todos already show what they do.
|
|
67
|
+
const HIDDEN_TOOLS = /^(Task(Create|Update|List|Get|Stop|Output)|TodoWrite|ToolSearch|advisor|task_notification)$/;
|
|
68
|
+
|
|
69
|
+
/** A repo-relative path, or null when the path is outside the agent's folder. */
|
|
70
|
+
export function relativePath(path: string, cwd: string): string | null {
|
|
71
|
+
let value = path.trim().replace(/\\/g, "/");
|
|
72
|
+
const root = cwd.trim().replace(/\\/g, "/").replace(/\/+$/, "");
|
|
73
|
+
if (root && value.startsWith(`${root}/`)) value = value.slice(root.length + 1);
|
|
74
|
+
else if (value.startsWith("/") || /^[A-Za-z]:\//.test(value)) return null;
|
|
75
|
+
value = value.replace(/^(\.\/)+/, "");
|
|
76
|
+
if (!value || value.split("/").includes("..")) return null;
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function text(value: unknown): string | null {
|
|
81
|
+
return typeof value === "string" ? value : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function lineCount(value: string | null): number {
|
|
85
|
+
if (!value) return 0;
|
|
86
|
+
return value.replace(/\n$/, "").split("\n").length;
|
|
87
|
+
}
|
|
88
|
+
|
|
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
|
+
}
|
|
94
|
+
const diff = text(detail.unifiedDiff);
|
|
95
|
+
if (diff) {
|
|
96
|
+
let added = 0;
|
|
97
|
+
let removed = 0;
|
|
98
|
+
for (const line of diff.split("\n")) {
|
|
99
|
+
if (line.startsWith("+") && !line.startsWith("+++")) added += 1;
|
|
100
|
+
else if (line.startsWith("-") && !line.startsWith("---")) removed += 1;
|
|
101
|
+
}
|
|
102
|
+
return { added, removed };
|
|
103
|
+
}
|
|
104
|
+
return { added: lineCount(text(detail.newString)), removed: lineCount(text(detail.oldString)) };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function toolStatus(value: unknown): ToolStatus {
|
|
108
|
+
return value === "running" || value === "failed" || value === "canceled" ? value : "completed";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function baseName(path: string): string {
|
|
112
|
+
return path.split("/").pop() ?? path;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Tool calls stream as repeated items with one callId. The last copy wins, in first position.
|
|
116
|
+
function latestToolCalls(items: readonly TimelineItemLike[]) {
|
|
117
|
+
const order: string[] = [];
|
|
118
|
+
const byId = new Map<string, TimelineItemLike>();
|
|
119
|
+
const started = new Map<string, number>();
|
|
120
|
+
items.forEach((item, index) => {
|
|
121
|
+
if (item.type !== "tool_call") return;
|
|
122
|
+
const id = text(item.callId) ?? `index-${index}`;
|
|
123
|
+
if (!byId.has(id)) {
|
|
124
|
+
order.push(id);
|
|
125
|
+
started.set(id, index);
|
|
126
|
+
}
|
|
127
|
+
byId.set(id, item);
|
|
128
|
+
});
|
|
129
|
+
return order.map((id) => ({ id, item: byId.get(id) as TimelineItemLike, start: started.get(id) ?? 0 }));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
interface FocusMark {
|
|
133
|
+
path: string;
|
|
134
|
+
/** Index of the item where the call started. */
|
|
135
|
+
at: number;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// The newest point at which the in-progress todo moved to another issue key.
|
|
139
|
+
function todoFocus(items: readonly TimelineItemLike[]): { key: string; at: number } | null {
|
|
140
|
+
let focus: { key: string; at: number } | null = null;
|
|
141
|
+
let active: string | null = null;
|
|
142
|
+
items.forEach((item, index) => {
|
|
143
|
+
if (item.type !== "todo" || !Array.isArray(item.items)) return;
|
|
144
|
+
const doing = (item.items as TodoLike[]).find((todo) => todoStatus(todo) === "in_progress");
|
|
145
|
+
const key = doing ? todoKey(doing.text) : null;
|
|
146
|
+
if (key && key !== active) focus = { key, at: index };
|
|
147
|
+
active = key;
|
|
148
|
+
});
|
|
149
|
+
return focus;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function deriveActivity(items: readonly TimelineItemLike[], cwd: string): Activity {
|
|
153
|
+
const files = new Map<string, FileTouch>();
|
|
154
|
+
const dirs = new Map<string, DirTouch>();
|
|
155
|
+
const events: ActivityEvent[] = [];
|
|
156
|
+
const subagents = new Map<string, SubagentRun>();
|
|
157
|
+
const subagentEvents = new Map<string, ActivityEvent>();
|
|
158
|
+
const resolve = (path: string) => relativePath(path, cwd);
|
|
159
|
+
let todos: TodoLike[] = [];
|
|
160
|
+
for (const item of items) {
|
|
161
|
+
if (item.type === "todo" && Array.isArray(item.items)) todos = item.items as TodoLike[];
|
|
162
|
+
}
|
|
163
|
+
const touch = (path: string, kind: TouchKind, order: number) => {
|
|
164
|
+
const entry = files.get(path) ?? {
|
|
165
|
+
path,
|
|
166
|
+
reads: 0,
|
|
167
|
+
edits: 0,
|
|
168
|
+
added: 0,
|
|
169
|
+
removed: 0,
|
|
170
|
+
created: false,
|
|
171
|
+
order,
|
|
172
|
+
last: kind,
|
|
173
|
+
};
|
|
174
|
+
entry.order = order;
|
|
175
|
+
entry.last = kind;
|
|
176
|
+
files.set(path, entry);
|
|
177
|
+
return entry;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
let fileFocus: FocusMark | null = null;
|
|
181
|
+
let runningFocus: FocusMark | null = null;
|
|
182
|
+
latestToolCalls(items).forEach(({ id, item, start }, order) => {
|
|
183
|
+
if (HIDDEN_TOOLS.test(text(item.name) ?? "")) return;
|
|
184
|
+
const signal = subagentOf(item, resolve);
|
|
185
|
+
if (!signal && isSubagentCall(item)) return;
|
|
186
|
+
if (signal) {
|
|
187
|
+
// The launch call and the subagent item are one run, so they make one feed row.
|
|
188
|
+
const run = mergeSubagent(subagents, signal, order);
|
|
189
|
+
const known = subagentEvents.get(run.key);
|
|
190
|
+
const row: ActivityEvent = known ?? {
|
|
191
|
+
id,
|
|
192
|
+
kind: "agent",
|
|
193
|
+
text: run.description ? `${run.type}: ${run.description}` : run.type,
|
|
194
|
+
detail: null,
|
|
195
|
+
path: null,
|
|
196
|
+
paths: [],
|
|
197
|
+
status: run.status,
|
|
198
|
+
added: 0,
|
|
199
|
+
removed: 0,
|
|
200
|
+
exitCode: null,
|
|
201
|
+
};
|
|
202
|
+
row.status = run.status;
|
|
203
|
+
row.paths = run.targets;
|
|
204
|
+
row.path = run.targets[0] ?? null;
|
|
205
|
+
if (!known) {
|
|
206
|
+
subagentEvents.set(run.key, row);
|
|
207
|
+
events.push(row);
|
|
208
|
+
}
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const detail = (item.detail ?? {}) as Record<string, unknown>;
|
|
212
|
+
const status = toolStatus(item.status);
|
|
213
|
+
const event: ActivityEvent = {
|
|
214
|
+
id,
|
|
215
|
+
kind: "tool",
|
|
216
|
+
text: text(item.name) ?? "Tool call",
|
|
217
|
+
detail: null,
|
|
218
|
+
path: null,
|
|
219
|
+
paths: [],
|
|
220
|
+
status,
|
|
221
|
+
added: 0,
|
|
222
|
+
removed: 0,
|
|
223
|
+
exitCode: null,
|
|
224
|
+
};
|
|
225
|
+
const raw = text(detail.filePath);
|
|
226
|
+
const path = raw ? relativePath(raw, cwd) : null;
|
|
227
|
+
if (path) event.paths = [path];
|
|
228
|
+
if (detail.type === "read" && raw) {
|
|
229
|
+
event.kind = "read";
|
|
230
|
+
event.path = path;
|
|
231
|
+
event.text = `Read ${path ?? baseName(raw)}`;
|
|
232
|
+
if (path) touch(path, "read", order).reads += 1;
|
|
233
|
+
} else if ((detail.type === "edit" || detail.type === "write") && raw) {
|
|
234
|
+
const write = detail.type === "write";
|
|
235
|
+
const counts = write && typeof detail.added !== "number"
|
|
236
|
+
? { added: lineCount(text(detail.content)), removed: 0 }
|
|
237
|
+
: diffCounts(detail);
|
|
238
|
+
event.kind = write ? "write" : "edit";
|
|
239
|
+
event.path = path;
|
|
240
|
+
event.added = counts.added;
|
|
241
|
+
event.removed = counts.removed;
|
|
242
|
+
event.text = `${write ? "Wrote" : "Edited"} ${path ?? baseName(raw)}`;
|
|
243
|
+
if (path && status !== "failed") {
|
|
244
|
+
const entry = touch(path, event.kind, order);
|
|
245
|
+
entry.edits += 1;
|
|
246
|
+
entry.added += counts.added;
|
|
247
|
+
entry.removed += counts.removed;
|
|
248
|
+
if (write && entry.reads === 0 && entry.edits === 1) entry.created = true;
|
|
249
|
+
}
|
|
250
|
+
} else if (detail.type === "search") {
|
|
251
|
+
event.kind = "search";
|
|
252
|
+
event.text = `Searched ${JSON.stringify(text(detail.query) ?? "")}`;
|
|
253
|
+
const hits = Array.isArray(detail.filePaths) ? detail.filePaths : [];
|
|
254
|
+
for (const hit of hits) {
|
|
255
|
+
const hitPath = typeof hit === "string" ? relativePath(hit, cwd) : null;
|
|
256
|
+
if (hitPath && !files.has(hitPath)) touch(hitPath, "search", order);
|
|
257
|
+
}
|
|
258
|
+
} else if (detail.type === "shell") {
|
|
259
|
+
const command = text(detail.command) ?? "";
|
|
260
|
+
const start = relativePath(text(detail.cwd) ?? "", cwd) ?? "";
|
|
261
|
+
const shell = shellActivity(command, cwd, start);
|
|
262
|
+
event.exitCode = typeof detail.exitCode === "number" ? detail.exitCode : null;
|
|
263
|
+
event.detail = displayCommand(command, cwd);
|
|
264
|
+
event.text = shell.summary;
|
|
265
|
+
event.paths = [...shell.writes.map((write) => write.path), ...shell.reads];
|
|
266
|
+
event.path = event.paths[0] ?? null;
|
|
267
|
+
event.kind = "shell";
|
|
268
|
+
if (shell.writes.length > 0) event.kind = "write";
|
|
269
|
+
else if (shell.reads.length > 0) event.kind = "read";
|
|
270
|
+
else if (shell.pattern !== null) event.kind = "search";
|
|
271
|
+
else if (shell.dirs.length > 0) event.kind = "list";
|
|
272
|
+
for (const read of shell.reads) touch(read, "read", order).reads += 1;
|
|
273
|
+
// A failed write may not have happened; reads count either way, as grep exits 1 on no match.
|
|
274
|
+
const succeeded = status === "completed" && (event.exitCode ?? 0) === 0;
|
|
275
|
+
for (const write of succeeded ? shell.writes : []) {
|
|
276
|
+
const entry = touch(write.path, write.mode, order);
|
|
277
|
+
entry.edits += 1;
|
|
278
|
+
if (write.mode === "write" && entry.reads === 0 && entry.edits === 1) entry.created = true;
|
|
279
|
+
}
|
|
280
|
+
for (const dir of shell.dirs) {
|
|
281
|
+
const known = dirs.get(dir.path);
|
|
282
|
+
dirs.set(dir.path, { path: dir.path, deep: dir.deep || (known?.deep ?? false), order });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const lead = event.path ?? event.paths[0];
|
|
286
|
+
if (lead && (event.kind === "read" || event.kind === "edit" || event.kind === "write")) {
|
|
287
|
+
if (!fileFocus || start >= fileFocus.at) fileFocus = { path: lead, at: start };
|
|
288
|
+
if (status === "running" && (!runningFocus || start >= runningFocus.at)) runningFocus = { path: lead, at: start };
|
|
289
|
+
}
|
|
290
|
+
events.push(event);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const recent = events.slice(-MAX_EVENTS).reverse();
|
|
294
|
+
const current = recent.find((event) => event.status === "running") ?? recent[0] ?? null;
|
|
295
|
+
// A running call wins; otherwise the newer of the last file and the last todo change.
|
|
296
|
+
const issueFocus = todoFocus(items);
|
|
297
|
+
const lastFile = fileFocus as FocusMark | null;
|
|
298
|
+
const running = runningFocus as FocusMark | null;
|
|
299
|
+
let focus: AgentFocus | null = null;
|
|
300
|
+
if (running) focus = { kind: "file", path: running.path };
|
|
301
|
+
else if (issueFocus && (!lastFile || issueFocus.at > lastFile.at)) focus = { kind: "issue", key: issueFocus.key };
|
|
302
|
+
else if (lastFile) focus = { kind: "file", path: lastFile.path };
|
|
303
|
+
return {
|
|
304
|
+
focus,
|
|
305
|
+
files: [...files.values()].sort((a, b) => b.order - a.order),
|
|
306
|
+
dirs: [...dirs.values()],
|
|
307
|
+
subagents: [...subagents.values()],
|
|
308
|
+
events: recent,
|
|
309
|
+
current,
|
|
310
|
+
todos,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
@@ -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
|
+
}
|