@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 { randomUUID } from "node:crypto";
|
|
2
|
+
import type { PluginHandlerContext, PluginLifecycleEvents } from "@getpaseo/plugin/server";
|
|
3
|
+
import { type ZodType, z } from "zod";
|
|
4
|
+
import { relativePath } from "../shared/activity";
|
|
5
|
+
import type { Job } from "../shared/live";
|
|
6
|
+
import { lastJsonObject } from "../shared/live";
|
|
7
|
+
|
|
8
|
+
// Explore and project setup run a read-only agent, wait for it in the background, and read a
|
|
9
|
+
// JSON answer from its last message. Claude ignores outputSchema, so the prompt asks for JSON.
|
|
10
|
+
|
|
11
|
+
export type Paseo = PluginHandlerContext["paseo"];
|
|
12
|
+
type WorkspaceHandle = Awaited<ReturnType<Paseo["workspaces"]["open"]>>;
|
|
13
|
+
type AgentHandle = ReturnType<Paseo["agents"]["ref"]>;
|
|
14
|
+
export type PermissionRequest = PluginLifecycleEvents["agent.permission_requested"]["request"];
|
|
15
|
+
export type PermissionAnswer = Parameters<AgentHandle["respondToPermission"]>[0]["response"];
|
|
16
|
+
|
|
17
|
+
export interface InternalRun<T> {
|
|
18
|
+
paseo: Paseo;
|
|
19
|
+
workspace: WorkspaceHandle;
|
|
20
|
+
/** Folder the agent works in; its reads must stay inside. */
|
|
21
|
+
cwd: string;
|
|
22
|
+
provider: string;
|
|
23
|
+
/** Thinking option for the model in `provider`; ignored when another model runs. */
|
|
24
|
+
effort?: string;
|
|
25
|
+
title: string;
|
|
26
|
+
prompt: string;
|
|
27
|
+
labels: Record<string, string>;
|
|
28
|
+
schema: ZodType<T>;
|
|
29
|
+
timeoutMs: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface SnapshotEntry {
|
|
33
|
+
provider: string;
|
|
34
|
+
status: string;
|
|
35
|
+
enabled?: boolean;
|
|
36
|
+
modes?: readonly { id: string; label?: string }[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const SAFE_MODES = [/read.?only/i, /^default$/i, /ask/i];
|
|
40
|
+
const UNSAFE_MODE = /bypass|full|yolo|danger/i;
|
|
41
|
+
|
|
42
|
+
// Never inherit a provider default that skips approvals. Claude's "default" asks before
|
|
43
|
+
// edits, so an edit attempt stops the run. Codex has no read-only mode; "auto" asks on request.
|
|
44
|
+
export function safeModeId(modes: SnapshotEntry["modes"]): string | undefined {
|
|
45
|
+
for (const pattern of SAFE_MODES) {
|
|
46
|
+
const match = modes?.find((mode) => pattern.test(mode.id));
|
|
47
|
+
if (match) return match.id;
|
|
48
|
+
}
|
|
49
|
+
return modes?.find((mode) => !UNSAFE_MODE.test(`${mode.id} ${mode.label ?? ""}`))?.id;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const UNSUPPORTED_KEYWORDS = new Set([
|
|
53
|
+
"$schema",
|
|
54
|
+
"default",
|
|
55
|
+
"minLength",
|
|
56
|
+
"maxLength",
|
|
57
|
+
"minItems",
|
|
58
|
+
"maxItems",
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
/** JSON schema for strict structured output: every property required, no extras. */
|
|
62
|
+
export function strictJsonSchema(schema: ZodType): Record<string, unknown> {
|
|
63
|
+
const strip = (node: unknown): unknown => {
|
|
64
|
+
if (Array.isArray(node)) return node.map(strip);
|
|
65
|
+
if (!node || typeof node !== "object") return node;
|
|
66
|
+
return Object.fromEntries(
|
|
67
|
+
Object.entries(node)
|
|
68
|
+
.filter(([key]) => !UNSUPPORTED_KEYWORDS.has(key))
|
|
69
|
+
.map(([key, value]) => [key, strip(value)]),
|
|
70
|
+
);
|
|
71
|
+
};
|
|
72
|
+
return strip(z.toJSONSchema(schema, { io: "output" })) as Record<string, unknown>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Some Claude builds search only through the shell, so internal agents may run a short list of
|
|
76
|
+
// commands that read. Anything that writes, runs other programs, or leaves the folder is denied.
|
|
77
|
+
const READ_COMMAND = /^(?:ls|find|rg|grep|cat|head|tail|wc|pwd|git (?:ls-files|grep))(?:\s|$)/;
|
|
78
|
+
const UNSAFE_SHELL =
|
|
79
|
+
/[`;&<>~$\n\r]|\.\.|\s-(?:exec|execdir|ok|okdir|delete|fprint\w*|fls|O)\b|\s--(?:pre|open-files-in-pager)\b/;
|
|
80
|
+
/** Quoted text the shell does not expand: single quotes, or double quotes without $ or `. */
|
|
81
|
+
const LITERAL = /'[^']*'|"[^"$`\\]*"/g;
|
|
82
|
+
|
|
83
|
+
function insideFolder(filePath: string, cwd: string): boolean {
|
|
84
|
+
return filePath.replace(/\/+$/, "") === cwd.replace(/\/+$/, "") || relativePath(filePath, cwd) !== null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function readOnlyCommand(command: string, cwd: string): boolean {
|
|
88
|
+
const text = command.replace(/\s2>\s*\/dev\/null/g, " ").trim();
|
|
89
|
+
const bare = text.replace(LITERAL, "Q");
|
|
90
|
+
if (!bare || UNSAFE_SHELL.test(bare)) return false;
|
|
91
|
+
const outside = text.split(/\s+|=/).some((word) => {
|
|
92
|
+
const filePath = word.replace(/['"]/g, "");
|
|
93
|
+
return filePath.startsWith("/") && !insideFolder(filePath, cwd);
|
|
94
|
+
});
|
|
95
|
+
return !outside && bare.split("|").every((part) => READ_COMMAND.test(part.trim()));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Prompt lines that match what readOnlyAnswer allows. */
|
|
99
|
+
export const READ_ONLY_RULES = [
|
|
100
|
+
"Read and search only. Do not create, edit, or delete files.",
|
|
101
|
+
"To find files, use your read and search tools, or these shell commands, one at a time and",
|
|
102
|
+
"without redirection: ls, find, rg, grep, cat, head, tail, wc, git ls-files, git grep.",
|
|
103
|
+
"Keep paths inside this folder. Other commands are denied.",
|
|
104
|
+
"Work alone: do not start subagents or background tasks.",
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
const DENIED =
|
|
108
|
+
"Not allowed in this read-only run. Read and search files in this repository only, one " +
|
|
109
|
+
"command at a time, without redirection.";
|
|
110
|
+
|
|
111
|
+
/** No one watches internal agents, so the plugin answers their prompts: reads yes, else no. */
|
|
112
|
+
export function readOnlyAnswer(request: PermissionRequest, cwd: string): PermissionAnswer {
|
|
113
|
+
if (request.kind === "question") {
|
|
114
|
+
return { behavior: "deny", message: "No one can answer here. Decide yourself and continue." };
|
|
115
|
+
}
|
|
116
|
+
const detail = request.detail;
|
|
117
|
+
const command =
|
|
118
|
+
detail?.type === "shell" ? detail.command : (request.input as { command?: unknown })?.command;
|
|
119
|
+
const allowed =
|
|
120
|
+
request.kind === "tool" &&
|
|
121
|
+
((detail?.type === "read" && insideFolder(detail.filePath, cwd)) ||
|
|
122
|
+
(detail?.type === "search" && detail.toolName !== "web_search") ||
|
|
123
|
+
(typeof command === "string" && readOnlyCommand(command, cwd)));
|
|
124
|
+
return allowed ? { behavior: "allow" } : { behavior: "deny", message: DENIED };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function entriesOf(snapshot: unknown): readonly SnapshotEntry[] {
|
|
128
|
+
const value = snapshot as {
|
|
129
|
+
entries?: readonly SnapshotEntry[];
|
|
130
|
+
compactSnapshot?: { entries?: readonly SnapshotEntry[] } | null;
|
|
131
|
+
};
|
|
132
|
+
const entries = value.entries ?? [];
|
|
133
|
+
return entries.length > 0 ? entries : (value.compactSnapshot?.entries ?? []);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Uses `preferred` (`provider` or `provider/model`) when ready, else the first ready provider. */
|
|
137
|
+
export async function agentConfigFor(paseo: Paseo, preferred: string) {
|
|
138
|
+
const entries = entriesOf(await paseo.providers.snapshot()).filter(
|
|
139
|
+
(entry) => entry.status === "ready" && entry.enabled !== false,
|
|
140
|
+
);
|
|
141
|
+
const wanted = preferred.split("/")[0] ?? "";
|
|
142
|
+
const entry = entries.find((candidate) => candidate.provider === wanted) ?? entries[0];
|
|
143
|
+
if (!entry) throw new Error("No agent provider is ready on this host");
|
|
144
|
+
const provider = entry.provider === wanted && preferred.includes("/") ? preferred : entry.provider;
|
|
145
|
+
const modeId = safeModeId(entry.modes);
|
|
146
|
+
return { provider, ...(modeId ? { modeId } : {}) };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function lastReply(handle: AgentHandle) {
|
|
150
|
+
const page = await handle.timeline.refetch({
|
|
151
|
+
direction: "tail",
|
|
152
|
+
limit: 60,
|
|
153
|
+
projection: "projected",
|
|
154
|
+
});
|
|
155
|
+
for (const entry of [...page.entries].reverse()) {
|
|
156
|
+
const item = entry.item as { type: string; text?: unknown };
|
|
157
|
+
if (item.type === "assistant_message" && typeof item.text === "string") return item.text;
|
|
158
|
+
}
|
|
159
|
+
return "";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const MAX_STALLS = 20;
|
|
163
|
+
const STALL_DELAY_MS = 500;
|
|
164
|
+
|
|
165
|
+
// waitForFinish also returns when the agent asks for permission, so answer and keep waiting.
|
|
166
|
+
// A prompt is answered once; while the daemon applies an answer, the loop waits briefly.
|
|
167
|
+
export async function waitUntilIdle(handle: AgentHandle, cwd: string, timeoutMs: number) {
|
|
168
|
+
const deadline = Date.now() + timeoutMs;
|
|
169
|
+
const answered = new Set<string>();
|
|
170
|
+
let stalls = 0;
|
|
171
|
+
for (;;) {
|
|
172
|
+
const remaining = deadline - Date.now();
|
|
173
|
+
if (remaining <= 0) throw new Error("The agent did not finish in time");
|
|
174
|
+
const finish = await handle.waitForFinish(remaining);
|
|
175
|
+
if (finish.status === "idle") return;
|
|
176
|
+
if (finish.status !== "permission") {
|
|
177
|
+
throw new Error(finish.error ?? `The agent stopped with status ${finish.status}`);
|
|
178
|
+
}
|
|
179
|
+
const unanswered = (requests: readonly PermissionRequest[] | null | undefined) =>
|
|
180
|
+
(requests ?? []).filter((request) => !answered.has(request.id));
|
|
181
|
+
let pending = unanswered(finish.final?.pendingPermissions);
|
|
182
|
+
if (pending.length === 0) {
|
|
183
|
+
await handle.refresh();
|
|
184
|
+
pending = unanswered(handle.pendingPermissions);
|
|
185
|
+
}
|
|
186
|
+
if (pending.length === 0) {
|
|
187
|
+
stalls += 1;
|
|
188
|
+
if (answered.size === 0 || stalls > MAX_STALLS) {
|
|
189
|
+
throw new Error("The agent is waiting for input");
|
|
190
|
+
}
|
|
191
|
+
await new Promise((resolve) => setTimeout(resolve, STALL_DELAY_MS));
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
stalls = 0;
|
|
195
|
+
for (const request of pending) {
|
|
196
|
+
answered.add(request.id);
|
|
197
|
+
await handle
|
|
198
|
+
.respondToPermission({ requestId: request.id, response: readOnlyAnswer(request, cwd) })
|
|
199
|
+
.catch((error: unknown) => console.error("Linear could not answer a permission", error));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Waits for an internal agent, reads its JSON answer, and archives it either way. */
|
|
205
|
+
export async function finishInternalRun<T>(
|
|
206
|
+
handle: AgentHandle,
|
|
207
|
+
cwd: string,
|
|
208
|
+
schema: ZodType<T>,
|
|
209
|
+
timeoutMs: number,
|
|
210
|
+
): Promise<T> {
|
|
211
|
+
try {
|
|
212
|
+
await waitUntilIdle(handle, cwd, timeoutMs);
|
|
213
|
+
const parsed = schema.safeParse(lastJsonObject(await lastReply(handle)));
|
|
214
|
+
if (!parsed.success) throw new Error("The agent did not return the expected JSON");
|
|
215
|
+
return parsed.data;
|
|
216
|
+
} finally {
|
|
217
|
+
await handle.archive().catch(() => undefined);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export interface StartedRun<T> {
|
|
222
|
+
agentId: string;
|
|
223
|
+
result: Promise<T>;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Adds the chosen effort only when the chosen model runs, not a fallback provider. */
|
|
227
|
+
export function withEffort<C extends { provider: string }>(config: C, chosen: string, effort?: string) {
|
|
228
|
+
const applies = Boolean(effort) && chosen.includes("/") && config.provider === chosen;
|
|
229
|
+
return applies ? { ...config, thinkingOptionId: effort as string } : config;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export async function startInternalRun<T>(run: InternalRun<T>): Promise<StartedRun<T>> {
|
|
233
|
+
const config = withEffort(await agentConfigFor(run.paseo, run.provider), run.provider, run.effort);
|
|
234
|
+
const handle = await run.workspace.agents.create({
|
|
235
|
+
config,
|
|
236
|
+
title: run.title,
|
|
237
|
+
prompt: run.prompt,
|
|
238
|
+
labels: run.labels,
|
|
239
|
+
outputSchema: strictJsonSchema(run.schema),
|
|
240
|
+
});
|
|
241
|
+
const result = finishInternalRun(handle, run.cwd, run.schema, run.timeoutMs);
|
|
242
|
+
return { agentId: handle.id, result };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export interface JobStore<T> {
|
|
246
|
+
get(id: string): { job: Job; value: T | null } | null;
|
|
247
|
+
start(id: string | null, begin: () => Promise<StartedRun<T>>): Promise<Job>;
|
|
248
|
+
/** Resolves with the job once it is done or failed; null for an unknown id. */
|
|
249
|
+
wait(id: string): Promise<Job | null>;
|
|
250
|
+
/** Drops a finished job, so the next start makes a new run. A running job is kept. */
|
|
251
|
+
forget(id: string): void;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
interface JobRecord<T> {
|
|
255
|
+
job: Job;
|
|
256
|
+
value: T | null;
|
|
257
|
+
settled: Promise<void>;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function createJobStore<T>(): JobStore<T> {
|
|
261
|
+
const jobs = new Map<string, JobRecord<T>>();
|
|
262
|
+
return {
|
|
263
|
+
get: (id) => {
|
|
264
|
+
const record = jobs.get(id);
|
|
265
|
+
return record ? { job: record.job, value: record.value } : null;
|
|
266
|
+
},
|
|
267
|
+
forget(id) {
|
|
268
|
+
if (jobs.get(id)?.job.status !== "running") jobs.delete(id);
|
|
269
|
+
},
|
|
270
|
+
async wait(id) {
|
|
271
|
+
const record = jobs.get(id);
|
|
272
|
+
if (!record) return null;
|
|
273
|
+
await record.settled;
|
|
274
|
+
return record.job;
|
|
275
|
+
},
|
|
276
|
+
async start(id, begin) {
|
|
277
|
+
const job: Job = {
|
|
278
|
+
id: id ?? randomUUID(),
|
|
279
|
+
status: "running",
|
|
280
|
+
agentId: null,
|
|
281
|
+
error: null,
|
|
282
|
+
startedAt: new Date().toISOString(),
|
|
283
|
+
};
|
|
284
|
+
let settle = () => {};
|
|
285
|
+
const settled = new Promise<void>((resolve) => {
|
|
286
|
+
settle = resolve;
|
|
287
|
+
});
|
|
288
|
+
const record: JobRecord<T> = { job, value: null, settled };
|
|
289
|
+
jobs.set(job.id, record);
|
|
290
|
+
const fail = (error: unknown) => {
|
|
291
|
+
record.job = {
|
|
292
|
+
...record.job,
|
|
293
|
+
status: "failed",
|
|
294
|
+
error: error instanceof Error ? error.message : String(error),
|
|
295
|
+
};
|
|
296
|
+
settle();
|
|
297
|
+
};
|
|
298
|
+
try {
|
|
299
|
+
const started = await begin();
|
|
300
|
+
record.job = { ...record.job, agentId: started.agentId };
|
|
301
|
+
started.result.then((value) => {
|
|
302
|
+
record.value = value;
|
|
303
|
+
record.job = { ...record.job, status: "done" };
|
|
304
|
+
settle();
|
|
305
|
+
}, fail);
|
|
306
|
+
} catch (error) {
|
|
307
|
+
fail(error);
|
|
308
|
+
}
|
|
309
|
+
return record.job;
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import {
|
|
6
|
+
AGENT_TOOLS,
|
|
7
|
+
agentToolsRpc,
|
|
8
|
+
childBrief,
|
|
9
|
+
EditIssueInput,
|
|
10
|
+
editText,
|
|
11
|
+
issueText,
|
|
12
|
+
LINEAR_TOOLS,
|
|
13
|
+
ReadIssueInput,
|
|
14
|
+
StartAgentInput,
|
|
15
|
+
TOOLS_SERVER,
|
|
16
|
+
taskCount,
|
|
17
|
+
type ToolScope,
|
|
18
|
+
ToolScopeSchema,
|
|
19
|
+
toolPolicy,
|
|
20
|
+
WaitAgentInput,
|
|
21
|
+
} from "../shared/agent-tools";
|
|
22
|
+
import { AGENT_LABELS } from "../shared/prompts";
|
|
23
|
+
import { lastReply, type Paseo } from "./agent-runs";
|
|
24
|
+
import type { LinearAccess } from "./handlers";
|
|
25
|
+
import { createMcpServer, listenLocal, type McpTool, tool } from "./mcp-http";
|
|
26
|
+
import type { LinearService } from "./queries";
|
|
27
|
+
import { createJsonFile } from "./stores";
|
|
28
|
+
|
|
29
|
+
// Each launch gets a grant: a random token for the agent's MCP config, and the issue tree it
|
|
30
|
+
// reaches. The file keeps the port and a hash of each token, never the token, so agents keep
|
|
31
|
+
// their tools after a restart.
|
|
32
|
+
|
|
33
|
+
const MAX_GRANTS = 500;
|
|
34
|
+
|
|
35
|
+
const GrantSchema = z.object({
|
|
36
|
+
id: z.string(),
|
|
37
|
+
hash: z.string(),
|
|
38
|
+
scope: ToolScopeSchema,
|
|
39
|
+
/** Only agents started from an issue may start agents, so the tree is one level deep. */
|
|
40
|
+
canStart: z.boolean(),
|
|
41
|
+
createdAt: z.string(),
|
|
42
|
+
});
|
|
43
|
+
export type Grant = z.infer<typeof GrantSchema>;
|
|
44
|
+
|
|
45
|
+
const FileSchema = z.object({ port: z.number().int().nullable(), grants: z.array(GrantSchema) });
|
|
46
|
+
|
|
47
|
+
const hashOf = (token: string) => createHash("sha256").update(token).digest("hex");
|
|
48
|
+
|
|
49
|
+
export interface AgentToolsDependencies {
|
|
50
|
+
access: LinearAccess;
|
|
51
|
+
dataDirectory: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The root issue or one of its sub-issues; any other key is refused. */
|
|
55
|
+
async function treeIssue(linear: LinearService, scope: ToolScope, key: string | undefined) {
|
|
56
|
+
const wanted = (key ?? scope.home).toUpperCase();
|
|
57
|
+
const root = await linear.getIssue(scope.root);
|
|
58
|
+
if (wanted === root.identifier.toUpperCase()) return root;
|
|
59
|
+
if (!root.children.some((child) => child.identifier.toUpperCase() === wanted)) {
|
|
60
|
+
throw new Error(`${wanted} is not ${root.identifier} or one of its sub-issues.`);
|
|
61
|
+
}
|
|
62
|
+
return linear.getIssue(wanted);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function linearTools(deps: AgentToolsDependencies, paseo: () => Paseo, mint: Mint): McpTool<Grant>[] {
|
|
66
|
+
const callers = new Map<string, string>();
|
|
67
|
+
async function callerAgent(api: Paseo, grant: Grant): Promise<string | null> {
|
|
68
|
+
const known = callers.get(grant.id);
|
|
69
|
+
if (known) return known;
|
|
70
|
+
const result = await api.agents.list({ filter: { labels: { [AGENT_LABELS.tools]: grant.id } }, page: { limit: 1 } });
|
|
71
|
+
const id = result.entries[0]?.agent.id ?? null;
|
|
72
|
+
if (id) callers.set(grant.id, id);
|
|
73
|
+
return id;
|
|
74
|
+
}
|
|
75
|
+
return [
|
|
76
|
+
tool({
|
|
77
|
+
name: "read_issue",
|
|
78
|
+
description: "Read a Linear issue of your issue tree: status, description with its task list, and sub-issues.",
|
|
79
|
+
input: ReadIssueInput,
|
|
80
|
+
async run(input, grant) {
|
|
81
|
+
const { linear } = await deps.access.connect(grant.scope.keyScope);
|
|
82
|
+
return issueText(await treeIssue(linear, grant.scope, input.issue));
|
|
83
|
+
},
|
|
84
|
+
}),
|
|
85
|
+
tool({
|
|
86
|
+
name: "edit_issue",
|
|
87
|
+
description:
|
|
88
|
+
"Change the description of a Linear issue of your issue tree. Replace exact text, for example `- [ ] Add the form` with `- [x] Add the form` to check off a task list item.",
|
|
89
|
+
input: EditIssueInput,
|
|
90
|
+
run(input, grant) {
|
|
91
|
+
return deps.access.mutate(grant.scope.keyScope, async (linear) => {
|
|
92
|
+
const issue = await treeIssue(linear, grant.scope, input.issue);
|
|
93
|
+
const edited = editText(issue.description ?? "", input.old_text, input.new_text);
|
|
94
|
+
if ("error" in edited) throw new Error(edited.error);
|
|
95
|
+
await linear.updateIssue(issue.id, { description: edited.body });
|
|
96
|
+
const tasks = taskCount(edited.body);
|
|
97
|
+
const count = tasks.total > 0 ? ` ${tasks.done} of ${tasks.total} task list items are checked.` : "";
|
|
98
|
+
return `Updated the description of ${issue.identifier}.${count}`;
|
|
99
|
+
});
|
|
100
|
+
},
|
|
101
|
+
}),
|
|
102
|
+
tool({
|
|
103
|
+
name: "start_agent",
|
|
104
|
+
description:
|
|
105
|
+
"Start a Paseo agent on a sub-issue, in your workspace, on your provider and model. It gets read_issue and edit_issue. Returns its id at once.",
|
|
106
|
+
input: StartAgentInput,
|
|
107
|
+
allowed: (grant) => grant.canStart,
|
|
108
|
+
async run(input, grant) {
|
|
109
|
+
const api = paseo();
|
|
110
|
+
const key = input.issue.toUpperCase();
|
|
111
|
+
const { linear } = await deps.access.connect(grant.scope.keyScope);
|
|
112
|
+
await treeIssue(linear, grant.scope, key);
|
|
113
|
+
const parent = await callerAgent(api, grant);
|
|
114
|
+
const child = await mint({ ...grant.scope, home: key }, false);
|
|
115
|
+
const { keyScope } = grant.scope;
|
|
116
|
+
const create = (config: ToolScope["config"]) =>
|
|
117
|
+
api.workspaces.ref(grant.scope.workspaceId).agents.create({
|
|
118
|
+
config: { ...config, mcpServers: { [TOOLS_SERVER]: child.server }, toolPolicy: child.toolPolicy },
|
|
119
|
+
...(parent ? { parent } : {}),
|
|
120
|
+
title: input.title,
|
|
121
|
+
prompt: `${childBrief(key, grant.scope.root)}\n\n${input.prompt}`,
|
|
122
|
+
labels: {
|
|
123
|
+
[AGENT_LABELS.issue]: key,
|
|
124
|
+
[AGENT_LABELS.action]: "implement",
|
|
125
|
+
...(keyScope ? { [AGENT_LABELS.project]: keyScope } : {}),
|
|
126
|
+
[AGENT_LABELS.tools]: child.grantId,
|
|
127
|
+
[AGENT_LABELS.startedBy]: grant.id,
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
const base = grant.scope.config;
|
|
131
|
+
let note = "";
|
|
132
|
+
let handle: Awaited<ReturnType<typeof create>>;
|
|
133
|
+
try {
|
|
134
|
+
handle = await create(input.thinking ? { ...base, thinkingOptionId: input.thinking } : base);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (!input.thinking) throw error;
|
|
137
|
+
handle = await create(base);
|
|
138
|
+
note = ` The model has no thinking option "${input.thinking}", so it uses yours.`;
|
|
139
|
+
}
|
|
140
|
+
return `Started agent ${handle.id} on ${key}.${note} Call wait_agent with agent_id "${handle.id}" to get its result.`;
|
|
141
|
+
},
|
|
142
|
+
}),
|
|
143
|
+
tool({
|
|
144
|
+
name: "wait_agent",
|
|
145
|
+
description: "Wait for an agent that start_agent started, and get its last reply. Call it again while the agent works.",
|
|
146
|
+
input: WaitAgentInput,
|
|
147
|
+
allowed: (grant) => grant.canStart,
|
|
148
|
+
async run(input, grant) {
|
|
149
|
+
const id = input.agent_id;
|
|
150
|
+
const handle = paseo().agents.ref(id);
|
|
151
|
+
const snapshot = (await handle.refresh().catch(() => null))?.agent;
|
|
152
|
+
if (snapshot?.labels?.[AGENT_LABELS.startedBy] !== grant.id) {
|
|
153
|
+
throw new Error(`Agent ${id} was not started by your start_agent calls.`);
|
|
154
|
+
}
|
|
155
|
+
const result = await handle.waitForFinish(input.timeout_seconds * 1000);
|
|
156
|
+
if (result.status === "timeout") return `Agent ${id} is still working. Call wait_agent again.`;
|
|
157
|
+
if (result.status === "permission") return `Agent ${id} waits for the user to answer a permission request in Paseo.`;
|
|
158
|
+
if (result.status === "error") return `Agent ${id} stopped with an error: ${result.error ?? "unknown error"}`;
|
|
159
|
+
const reply = result.lastMessage ?? (await lastReply(handle));
|
|
160
|
+
return `Agent ${id} finished.\n\nIts last reply:\n${reply || "(no reply)"}`;
|
|
161
|
+
},
|
|
162
|
+
}),
|
|
163
|
+
];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
type Minted = z.infer<typeof agentToolsRpc.output>;
|
|
167
|
+
type Mint = (scope: ToolScope, canStart: boolean) => Promise<Minted>;
|
|
168
|
+
|
|
169
|
+
export function registerAgentTools(server: PluginServerContext, deps: AgentToolsDependencies) {
|
|
170
|
+
const file = createJsonFile(path.join(deps.dataDirectory, "agent-tools.json"), FileSchema, () => ({
|
|
171
|
+
port: null,
|
|
172
|
+
grants: [],
|
|
173
|
+
}));
|
|
174
|
+
// The SDK hands Paseo to handlers and hooks only. The subprocess has one, so keep the latest.
|
|
175
|
+
let api: Paseo | null = null;
|
|
176
|
+
const remember = (_event: unknown, context: { paseo: Paseo }) => {
|
|
177
|
+
api = context.paseo;
|
|
178
|
+
};
|
|
179
|
+
server.on("agent.turn_started", remember);
|
|
180
|
+
server.on("agent.turn_ended", remember);
|
|
181
|
+
server.on("agent.created", remember);
|
|
182
|
+
const paseo = () => {
|
|
183
|
+
if (!api) throw new Error("Paseo is not ready yet. Try again in a few seconds.");
|
|
184
|
+
return api;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
let listening: Promise<number> | null = null;
|
|
188
|
+
const ensureListening = () => {
|
|
189
|
+
listening ??= file
|
|
190
|
+
.read()
|
|
191
|
+
.then((current) => listenLocal(http, current.port))
|
|
192
|
+
.then(async (port) => {
|
|
193
|
+
await file.update((current) => ({ next: { ...current, port }, result: null }));
|
|
194
|
+
return port;
|
|
195
|
+
});
|
|
196
|
+
return listening;
|
|
197
|
+
};
|
|
198
|
+
const mint: Mint = async (scope, canStart) => {
|
|
199
|
+
const port = await ensureListening();
|
|
200
|
+
const token = randomBytes(32).toString("base64url");
|
|
201
|
+
const grant: Grant = {
|
|
202
|
+
id: randomBytes(8).toString("hex"),
|
|
203
|
+
hash: hashOf(token),
|
|
204
|
+
scope,
|
|
205
|
+
canStart,
|
|
206
|
+
createdAt: new Date().toISOString(),
|
|
207
|
+
};
|
|
208
|
+
await file.update((current) => ({ next: { ...current, grants: [...current.grants, grant].slice(-MAX_GRANTS) }, result: null }));
|
|
209
|
+
return {
|
|
210
|
+
grantId: grant.id,
|
|
211
|
+
server: { type: "http", url: `http://127.0.0.1:${port}/mcp`, headers: { Authorization: `Bearer ${token}` } },
|
|
212
|
+
toolPolicy: toolPolicy(canStart ? [...LINEAR_TOOLS, ...AGENT_TOOLS] : [...LINEAR_TOOLS]),
|
|
213
|
+
};
|
|
214
|
+
};
|
|
215
|
+
const http = createMcpServer<Grant>({
|
|
216
|
+
name: "paseo-linear",
|
|
217
|
+
version: "1.0.0",
|
|
218
|
+
tools: linearTools(deps, paseo, mint),
|
|
219
|
+
async authorize(token) {
|
|
220
|
+
const hash = hashOf(token);
|
|
221
|
+
return (await file.read()).grants.find((grant) => grant.hash === hash) ?? null;
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
server.handle(agentToolsRpc, async (scope, context) => {
|
|
226
|
+
api = context.paseo;
|
|
227
|
+
return mint(scope, true);
|
|
228
|
+
});
|
|
229
|
+
// Agents from before a restart call the same port, so listen again when grants exist.
|
|
230
|
+
void file
|
|
231
|
+
.read()
|
|
232
|
+
.then((current) => (current.grants.length > 0 ? ensureListening() : null))
|
|
233
|
+
.catch((error: unknown) => console.error("Linear MCP could not start", error));
|
|
234
|
+
|
|
235
|
+
return () =>
|
|
236
|
+
new Promise<void>((resolve) => {
|
|
237
|
+
http.closeAllConnections();
|
|
238
|
+
http.close(() => resolve());
|
|
239
|
+
});
|
|
240
|
+
}
|
package/server/handlers.ts
CHANGED
|
@@ -37,7 +37,16 @@ function listKey(query: IssueQuery) {
|
|
|
37
37
|
return { teamId, assignee, status, query: text.trim(), sort, after };
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
/** Linear access for the sync, explore, and setup handlers. */
|
|
41
|
+
export interface LinearAccess {
|
|
42
|
+
connect(projectId: ProjectId): Promise<{ linear: LinearService; fingerprint: string }>;
|
|
43
|
+
mutate<T>(projectId: ProjectId, run: (linear: LinearService) => Promise<T>): Promise<T>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function registerHandlers(
|
|
47
|
+
server: PluginServerContext,
|
|
48
|
+
dependencies: HandlerDependencies,
|
|
49
|
+
): LinearAccess {
|
|
41
50
|
const { credentials, endpoint } = dependencies;
|
|
42
51
|
const cache =
|
|
43
52
|
dependencies.cache ?? createResponseCache({ maxEntries: 300, maxAgeMs: 24 * 60 * 60 * 1000 });
|
|
@@ -169,4 +178,6 @@ export function registerHandlers(server: PluginServerContext, dependencies: Hand
|
|
|
169
178
|
return { attached: false };
|
|
170
179
|
}
|
|
171
180
|
});
|
|
181
|
+
|
|
182
|
+
return { connect, mutate };
|
|
172
183
|
}
|
package/server/init.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
|
2
|
+
import { type InitProposal, InitProposalSchema, initStartRpc, initStatusRpc } from "../shared/live";
|
|
3
|
+
import { AGENT_LABELS } from "../shared/prompts";
|
|
4
|
+
import type { LinearSettings } from "../shared/settings";
|
|
5
|
+
import { createJobStore, READ_ONLY_RULES, startInternalRun } from "./agent-runs";
|
|
6
|
+
|
|
7
|
+
export function initPrompt(rootPath: string): string {
|
|
8
|
+
return [
|
|
9
|
+
`Set up Linear agent prompts for the repository at ${rootPath}.`,
|
|
10
|
+
"",
|
|
11
|
+
...READ_ONLY_RULES,
|
|
12
|
+
"",
|
|
13
|
+
"Read the guidance the repository gives agents and contributors: AGENTS.md, CLAUDE.md and",
|
|
14
|
+
"similar files, CONTRIBUTING, the README, package scripts or build files, CI workflows, pull",
|
|
15
|
+
"request templates, commit conventions, and the test setup.",
|
|
16
|
+
"",
|
|
17
|
+
"Then write:",
|
|
18
|
+
"- instructions: up to 12 short lines an agent must follow here: commands, conventions, limits.",
|
|
19
|
+
'- steps: 3 to 8 short checks an agent runs before it finishes, such as "Run npm run check".',
|
|
20
|
+
"- implement: extra guidance for implementing an issue in this repository, or an empty string.",
|
|
21
|
+
"- review: extra guidance for reviewing a change in this repository, or an empty string.",
|
|
22
|
+
"Use only facts you found in the repository. Do not repeat generic advice.",
|
|
23
|
+
"",
|
|
24
|
+
"Reply with only a JSON object in a ```json block:",
|
|
25
|
+
'{"instructions":"...","steps":["..."],"implement":"...","review":"..."}',
|
|
26
|
+
].join("\n");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface InitDependencies {
|
|
30
|
+
readSettings(): Promise<LinearSettings | null>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function registerInit(server: PluginServerContext, dependencies: InitDependencies) {
|
|
34
|
+
const jobs = createJobStore<InitProposal>();
|
|
35
|
+
|
|
36
|
+
server.handle(initStartRpc, async ({ projectId, rootPath }, { paseo }) => {
|
|
37
|
+
// Only known Paseo projects can be set up, so the client cannot point an agent anywhere.
|
|
38
|
+
const { projects } = await paseo.projects.list();
|
|
39
|
+
const project = projects.find((entry) => entry.projectId === projectId);
|
|
40
|
+
if (!project || project.projectRootPath !== rootPath) {
|
|
41
|
+
throw new Error("That project is not on this host");
|
|
42
|
+
}
|
|
43
|
+
const settings = await dependencies.readSettings();
|
|
44
|
+
const job = await jobs.start(null, async () =>
|
|
45
|
+
startInternalRun({
|
|
46
|
+
paseo,
|
|
47
|
+
workspace: await paseo.workspaces.open(rootPath),
|
|
48
|
+
cwd: rootPath,
|
|
49
|
+
provider: settings?.live.exploreProvider || settings?.launch.provider || "",
|
|
50
|
+
effort: settings?.live.exploreProvider ? settings.live.exploreEffort : "",
|
|
51
|
+
title: `Set up Linear prompts for ${project.projectDisplayName}`,
|
|
52
|
+
prompt: initPrompt(rootPath),
|
|
53
|
+
labels: { [AGENT_LABELS.init]: projectId },
|
|
54
|
+
schema: InitProposalSchema,
|
|
55
|
+
timeoutMs: 20 * 60_000,
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
return { job };
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
server.handle(initStatusRpc, async ({ jobId }) => {
|
|
62
|
+
const record = jobs.get(jobId);
|
|
63
|
+
return { job: record?.job ?? null, proposal: record?.value ?? null };
|
|
64
|
+
});
|
|
65
|
+
}
|