@yannelli/paseo-linear-plugin 1.1.0-beta.0 → 1.1.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/OVERVIEW.md +7 -3
- package/README.md +38 -11
- package/client/access.tsx +2 -1
- package/client/compat.ts +23 -5
- package/client/connect.tsx +3 -2
- 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/pill.tsx +4 -3
- package/client/plugin-icon.tsx +43 -0
- package/client/settings-fields.tsx +11 -3
- package/client/settings-icon.tsx +228 -0
- package/client/settings-live.tsx +25 -1
- package/client/timeline-card.tsx +2 -1
- package/client/tool-buttons.tsx +68 -0
- package/client/ui.tsx +16 -7
- package/client/web.ts +91 -0
- package/index.client.tsx +9 -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/custom-icon.ts +148 -0
- package/shared/graph-camera.ts +93 -0
- package/shared/live.ts +43 -0
- package/shared/prompts.ts +49 -1
- package/shared/settings.ts +34 -0
package/server/agent-runs.ts
CHANGED
|
@@ -20,6 +20,8 @@ export interface InternalRun<T> {
|
|
|
20
20
|
/** Folder the agent works in; its reads must stay inside. */
|
|
21
21
|
cwd: string;
|
|
22
22
|
provider: string;
|
|
23
|
+
/** Thinking option for the model in `provider`; ignored when another model runs. */
|
|
24
|
+
effort?: string;
|
|
23
25
|
title: string;
|
|
24
26
|
prompt: string;
|
|
25
27
|
labels: Record<string, string>;
|
|
@@ -144,7 +146,7 @@ export async function agentConfigFor(paseo: Paseo, preferred: string) {
|
|
|
144
146
|
return { provider, ...(modeId ? { modeId } : {}) };
|
|
145
147
|
}
|
|
146
148
|
|
|
147
|
-
async function lastReply(handle: AgentHandle) {
|
|
149
|
+
export async function lastReply(handle: AgentHandle) {
|
|
148
150
|
const page = await handle.timeline.refetch({
|
|
149
151
|
direction: "tail",
|
|
150
152
|
limit: 60,
|
|
@@ -221,8 +223,14 @@ export interface StartedRun<T> {
|
|
|
221
223
|
result: Promise<T>;
|
|
222
224
|
}
|
|
223
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
|
+
|
|
224
232
|
export async function startInternalRun<T>(run: InternalRun<T>): Promise<StartedRun<T>> {
|
|
225
|
-
const config = await agentConfigFor(run.paseo, run.provider);
|
|
233
|
+
const config = withEffort(await agentConfigFor(run.paseo, run.provider), run.provider, run.effort);
|
|
226
234
|
const handle = await run.workspace.agents.create({
|
|
227
235
|
config,
|
|
228
236
|
title: run.title,
|
|
@@ -239,6 +247,8 @@ export interface JobStore<T> {
|
|
|
239
247
|
start(id: string | null, begin: () => Promise<StartedRun<T>>): Promise<Job>;
|
|
240
248
|
/** Resolves with the job once it is done or failed; null for an unknown id. */
|
|
241
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;
|
|
242
252
|
}
|
|
243
253
|
|
|
244
254
|
interface JobRecord<T> {
|
|
@@ -254,6 +264,9 @@ export function createJobStore<T>(): JobStore<T> {
|
|
|
254
264
|
const record = jobs.get(id);
|
|
255
265
|
return record ? { job: record.job, value: record.value } : null;
|
|
256
266
|
},
|
|
267
|
+
forget(id) {
|
|
268
|
+
if (jobs.get(id)?.job.status !== "running") jobs.delete(id);
|
|
269
|
+
},
|
|
257
270
|
async wait(id) {
|
|
258
271
|
const record = jobs.get(id);
|
|
259
272
|
if (!record) return null;
|
|
@@ -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/init.ts
CHANGED
|
@@ -47,6 +47,7 @@ export function registerInit(server: PluginServerContext, dependencies: InitDepe
|
|
|
47
47
|
workspace: await paseo.workspaces.open(rootPath),
|
|
48
48
|
cwd: rootPath,
|
|
49
49
|
provider: settings?.live.exploreProvider || settings?.launch.provider || "",
|
|
50
|
+
effort: settings?.live.exploreProvider ? settings.live.exploreEffort : "",
|
|
50
51
|
title: `Set up Linear prompts for ${project.projectDisplayName}`,
|
|
51
52
|
prompt: initPrompt(rootPath),
|
|
52
53
|
labels: { [AGENT_LABELS.init]: projectId },
|
package/server/live.ts
CHANGED
|
@@ -11,10 +11,13 @@ import {
|
|
|
11
11
|
type Job,
|
|
12
12
|
type LaunchInput,
|
|
13
13
|
launchAfterExploreRpc,
|
|
14
|
+
clearMapRpc,
|
|
14
15
|
linksRpc,
|
|
15
16
|
listFilesRpc,
|
|
17
|
+
subagentLogsRpc,
|
|
16
18
|
liveMapRpc,
|
|
17
19
|
MAX_DIR_ENTRIES,
|
|
20
|
+
MAX_EXPLORE_FILES,
|
|
18
21
|
} from "../shared/live";
|
|
19
22
|
import { AGENT_LABELS, issueSnapshot } from "../shared/prompts";
|
|
20
23
|
import { type LinearSettings, projectEnabled } from "../shared/settings";
|
|
@@ -27,6 +30,7 @@ import {
|
|
|
27
30
|
} from "./agent-runs";
|
|
28
31
|
import type { LinearAccess } from "./handlers";
|
|
29
32
|
import { fileLinks } from "./links";
|
|
33
|
+
import { subagentLogs } from "./subagent-logs";
|
|
30
34
|
import type { LaunchQueue, MapStore } from "./stores";
|
|
31
35
|
|
|
32
36
|
const EXPLORE_TIMEOUT_MS = 20 * 60_000;
|
|
@@ -41,6 +45,8 @@ export interface AgentInfo {
|
|
|
41
45
|
/** Paseo project of the agent's workspace, when the workspace is known. */
|
|
42
46
|
projectId: string | null;
|
|
43
47
|
provider: string;
|
|
48
|
+
/** The provider's session, such as Claude's session id. */
|
|
49
|
+
sessionId: string | null;
|
|
44
50
|
labels: Record<string, string>;
|
|
45
51
|
}
|
|
46
52
|
|
|
@@ -62,6 +68,7 @@ export async function agentInfo(paseo: Paseo, agentId: string): Promise<AgentInf
|
|
|
62
68
|
workspaceId,
|
|
63
69
|
projectId: workspace?.projectId ?? null,
|
|
64
70
|
provider: agent.provider,
|
|
71
|
+
sessionId: agent.runtimeInfo?.sessionId ?? null,
|
|
65
72
|
labels: agent.labels ?? {},
|
|
66
73
|
};
|
|
67
74
|
}
|
|
@@ -100,7 +107,7 @@ export function explorePrompt(issue: IssueDetail): string {
|
|
|
100
107
|
issueSnapshot(issue, { includeComments: false }),
|
|
101
108
|
"",
|
|
102
109
|
"List the files an engineer will most likely read or change for this issue and each sub-issue.",
|
|
103
|
-
|
|
110
|
+
`Include files that will probably be created, at their expected path. List at most ${MAX_EXPLORE_FILES} files.`,
|
|
104
111
|
`Set "issue" to the sub-issue key the file belongs to, or ${issue.identifier} for the whole issue.`,
|
|
105
112
|
`Valid keys: ${keys.join(", ")}.`,
|
|
106
113
|
"Reply with only a JSON object in a ```json block, with repo-relative paths:",
|
|
@@ -223,6 +230,21 @@ export function registerLive(server: PluginServerContext, dependencies: LiveDepe
|
|
|
223
230
|
return { links: await fileLinks(agent.cwd, files) };
|
|
224
231
|
});
|
|
225
232
|
|
|
233
|
+
server.handle(clearMapRpc, async ({ agentId }, { paseo }) => {
|
|
234
|
+
const agent = await agentInfo(paseo, agentId);
|
|
235
|
+
const identifier = agent.labels[AGENT_LABELS.issue];
|
|
236
|
+
if (!identifier) throw new Error("This agent is not linked to a Linear issue");
|
|
237
|
+
if (jobs.get(identifier)?.job.status === "running") throw new Error("The explore agent is still mapping this issue");
|
|
238
|
+
await maps.delete(identifier);
|
|
239
|
+
jobs.forget(identifier);
|
|
240
|
+
return { cleared: true };
|
|
241
|
+
});
|
|
242
|
+
server.handle(subagentLogsRpc, async ({ agentId }, { paseo }) => {
|
|
243
|
+
const agent = await agentInfo(paseo, agentId);
|
|
244
|
+
// Only Claude keeps subagent transcripts this plugin can read.
|
|
245
|
+
if (agent.provider.split("/")[0] !== "claude" || !agent.sessionId) return { runs: [] };
|
|
246
|
+
return { runs: await subagentLogs(agent.sessionId, agent.cwd) };
|
|
247
|
+
});
|
|
226
248
|
server.handle(liveMapRpc, async ({ identifier }, { paseo }) => {
|
|
227
249
|
const map = await maps.get(identifier);
|
|
228
250
|
let job = jobs.get(identifier)?.job ?? null;
|
|
@@ -249,6 +271,7 @@ export function registerLive(server: PluginServerContext, dependencies: LiveDepe
|
|
|
249
271
|
/** Opened only when a new run starts. */
|
|
250
272
|
workspace: () => Promise<WorkspaceHandle>;
|
|
251
273
|
provider: string;
|
|
274
|
+
effort: string;
|
|
252
275
|
force: boolean;
|
|
253
276
|
}
|
|
254
277
|
|
|
@@ -275,6 +298,7 @@ export function registerLive(server: PluginServerContext, dependencies: LiveDepe
|
|
|
275
298
|
workspace: await target.workspace(),
|
|
276
299
|
cwd: target.cwd,
|
|
277
300
|
provider: target.provider,
|
|
301
|
+
effort: target.effort,
|
|
278
302
|
title: `Explore ${identifier}`,
|
|
279
303
|
prompt: explorePrompt(issue),
|
|
280
304
|
labels,
|
|
@@ -306,6 +330,7 @@ export function registerLive(server: PluginServerContext, dependencies: LiveDepe
|
|
|
306
330
|
workspace: async () =>
|
|
307
331
|
workspaceId ? paseo.workspaces.ref(workspaceId) : paseo.workspaces.open(cwd),
|
|
308
332
|
provider: settings?.live.exploreProvider || agent.provider,
|
|
333
|
+
effort: settings?.live.exploreEffort ?? "",
|
|
309
334
|
force,
|
|
310
335
|
});
|
|
311
336
|
return { job };
|
|
@@ -322,6 +347,7 @@ export function registerLive(server: PluginServerContext, dependencies: LiveDepe
|
|
|
322
347
|
cwd: info.workspaceDirectory ?? info.projectRootPath,
|
|
323
348
|
workspace: async () => workspace,
|
|
324
349
|
provider: settings?.live.exploreProvider || input.agent.config.provider,
|
|
350
|
+
effort: settings?.live.exploreEffort ?? "",
|
|
325
351
|
force: false,
|
|
326
352
|
});
|
|
327
353
|
// A failed or timed-out explore run still starts the agent; the map falls back to ticket text.
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
2
|
+
import type { AddressInfo } from "node:net";
|
|
3
|
+
import { type ZodType, z } from "zod";
|
|
4
|
+
|
|
5
|
+
// A small MCP server over HTTP for agents on this host. It sends plain JSON responses, which the
|
|
6
|
+
// streamable HTTP transport allows, so it needs no sessions and no event streams.
|
|
7
|
+
|
|
8
|
+
export interface McpTool<C> {
|
|
9
|
+
name: string;
|
|
10
|
+
description: string;
|
|
11
|
+
input: ZodType;
|
|
12
|
+
/** Callers that may list and call the tool; all callers when absent. */
|
|
13
|
+
allowed?(caller: C): boolean;
|
|
14
|
+
run(input: unknown, caller: C): Promise<string>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A tool whose run gets its parsed input. */
|
|
18
|
+
export function tool<S extends ZodType, C>(
|
|
19
|
+
definition: Omit<McpTool<C>, "input" | "run"> & { input: S; run(input: z.output<S>, caller: C): Promise<string> },
|
|
20
|
+
): McpTool<C> {
|
|
21
|
+
return definition as unknown as McpTool<C>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface McpOptions<C> {
|
|
25
|
+
name: string;
|
|
26
|
+
version: string;
|
|
27
|
+
tools: readonly McpTool<C>[];
|
|
28
|
+
/** The caller for a bearer token, or null to refuse the request. */
|
|
29
|
+
authorize(token: string): Promise<C | null>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
|
|
33
|
+
const MAX_BODY = 1_000_000;
|
|
34
|
+
|
|
35
|
+
type Message = { jsonrpc?: unknown; id?: unknown; method?: unknown; params?: unknown };
|
|
36
|
+
|
|
37
|
+
const failure = (id: unknown, code: number, message: string) => ({ jsonrpc: "2.0", id: id ?? null, error: { code, message } });
|
|
38
|
+
const success = (id: unknown, result: unknown) => ({ jsonrpc: "2.0", id, result });
|
|
39
|
+
const text = (value: string, isError = false) => ({ content: [{ type: "text", text: value }], ...(isError ? { isError } : {}) });
|
|
40
|
+
|
|
41
|
+
function errorText(error: unknown): string {
|
|
42
|
+
if (error instanceof z.ZodError) return error.issues.map((issue) => `${issue.path.join(".") || "input"}: ${issue.message}`).join("; ");
|
|
43
|
+
return error instanceof Error ? error.message : String(error);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The answer to one JSON-RPC message, or null for a notification. */
|
|
47
|
+
export async function answer<C>(options: McpOptions<C>, message: Message, caller: C): Promise<object | null> {
|
|
48
|
+
if (typeof message !== "object" || message === null || message.jsonrpc !== "2.0" || typeof message.method !== "string") {
|
|
49
|
+
return failure(message?.id, -32600, "Invalid request");
|
|
50
|
+
}
|
|
51
|
+
if (message.id === undefined || message.id === null) return null;
|
|
52
|
+
const params = (typeof message.params === "object" && message.params !== null ? message.params : {}) as Record<string, unknown>;
|
|
53
|
+
const tools = options.tools.filter((tool) => tool.allowed?.(caller) ?? true);
|
|
54
|
+
switch (message.method) {
|
|
55
|
+
case "initialize": {
|
|
56
|
+
const asked = typeof params.protocolVersion === "string" ? params.protocolVersion : "";
|
|
57
|
+
return success(message.id, {
|
|
58
|
+
protocolVersion: VERSIONS.includes(asked) ? asked : VERSIONS[0],
|
|
59
|
+
capabilities: { tools: { listChanged: false } },
|
|
60
|
+
serverInfo: { name: options.name, version: options.version },
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
case "ping":
|
|
64
|
+
return success(message.id, {});
|
|
65
|
+
case "tools/list":
|
|
66
|
+
return success(message.id, {
|
|
67
|
+
tools: tools.map((tool) => ({
|
|
68
|
+
name: tool.name,
|
|
69
|
+
description: tool.description,
|
|
70
|
+
inputSchema: z.toJSONSchema(tool.input, { io: "input" }),
|
|
71
|
+
})),
|
|
72
|
+
});
|
|
73
|
+
case "tools/call": {
|
|
74
|
+
const tool = tools.find((entry) => entry.name === params.name);
|
|
75
|
+
if (!tool) return failure(message.id, -32602, `Unknown tool: ${String(params.name)}`);
|
|
76
|
+
try {
|
|
77
|
+
return success(message.id, text(await tool.run(tool.input.parse(params.arguments ?? {}), caller)));
|
|
78
|
+
} catch (error) {
|
|
79
|
+
return success(message.id, text(errorText(error), true));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
default:
|
|
83
|
+
return failure(message.id, -32601, `Unknown method: ${message.method}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function readBody(request: IncomingMessage): Promise<string | null> {
|
|
88
|
+
return new Promise((resolve, reject) => {
|
|
89
|
+
const chunks: Buffer[] = [];
|
|
90
|
+
let size = 0;
|
|
91
|
+
request.on("data", (chunk: Buffer) => {
|
|
92
|
+
size += chunk.length;
|
|
93
|
+
if (size > MAX_BODY) {
|
|
94
|
+
resolve(null);
|
|
95
|
+
request.destroy();
|
|
96
|
+
} else chunks.push(chunk);
|
|
97
|
+
});
|
|
98
|
+
request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
99
|
+
request.on("error", reject);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function send(response: ServerResponse, status: number, body?: unknown) {
|
|
104
|
+
response.writeHead(status, body === undefined ? {} : { "content-type": "application/json" });
|
|
105
|
+
response.end(body === undefined ? undefined : JSON.stringify(body));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Browsers send an Origin header and agents do not, so a web page can never reach the tools.
|
|
109
|
+
export function createMcpServer<C>(options: McpOptions<C>): Server {
|
|
110
|
+
return createServer(async (request, response) => {
|
|
111
|
+
try {
|
|
112
|
+
if (new URL(request.url ?? "/", "http://localhost").pathname !== "/mcp") return send(response, 404);
|
|
113
|
+
if (request.headers.origin) return send(response, 403);
|
|
114
|
+
if (request.method !== "POST") {
|
|
115
|
+
response.setHeader("allow", "POST");
|
|
116
|
+
return send(response, 405);
|
|
117
|
+
}
|
|
118
|
+
const token = /^Bearer (\S+)$/.exec(request.headers.authorization ?? "")?.[1];
|
|
119
|
+
const caller = token ? await options.authorize(token) : null;
|
|
120
|
+
if (caller === null) return send(response, 401, failure(null, -32001, "Unknown token"));
|
|
121
|
+
const raw = await readBody(request);
|
|
122
|
+
if (raw === null) return send(response, 413);
|
|
123
|
+
let parsed: unknown;
|
|
124
|
+
try {
|
|
125
|
+
parsed = JSON.parse(raw);
|
|
126
|
+
} catch {
|
|
127
|
+
return send(response, 400, failure(null, -32700, "Parse error"));
|
|
128
|
+
}
|
|
129
|
+
const messages = (Array.isArray(parsed) ? parsed : [parsed]) as Message[];
|
|
130
|
+
const answers = (await Promise.all(messages.map((message) => answer(options, message, caller)))).filter(Boolean);
|
|
131
|
+
if (answers.length === 0) return send(response, 202);
|
|
132
|
+
send(response, 200, Array.isArray(parsed) ? answers : answers[0]);
|
|
133
|
+
} catch (error) {
|
|
134
|
+
console.error("Linear MCP request failed", error);
|
|
135
|
+
if (!response.headersSent) send(response, 500);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Listens on 127.0.0.1, on the preferred port when it is free. Returns the port. */
|
|
141
|
+
export function listenLocal(server: Server, preferred: number | null): Promise<number> {
|
|
142
|
+
const attempt = (port: number) =>
|
|
143
|
+
new Promise<number>((resolve, reject) => {
|
|
144
|
+
const fail = (error: Error) => reject(error);
|
|
145
|
+
server.once("error", fail);
|
|
146
|
+
server.listen(port, "127.0.0.1", () => {
|
|
147
|
+
server.off("error", fail);
|
|
148
|
+
resolve((server.address() as AddressInfo).port);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
return preferred ? attempt(preferred).catch(() => attempt(0)) : attempt(0);
|
|
152
|
+
}
|
package/server/stores.ts
CHANGED
|
@@ -13,7 +13,7 @@ interface JsonFile<T> {
|
|
|
13
13
|
update<R>(change: (current: T) => { next: T; result: R }): Promise<R>;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
function createJsonFile<T>(file: string, schema: ZodType<T>, empty: () => T): JsonFile<T> {
|
|
16
|
+
export function createJsonFile<T>(file: string, schema: ZodType<T>, empty: () => T): JsonFile<T> {
|
|
17
17
|
let value: T | null = null;
|
|
18
18
|
let queue: Promise<unknown> = Promise.resolve();
|
|
19
19
|
async function load(): Promise<T> {
|
|
@@ -51,6 +51,7 @@ function createJsonFile<T>(file: string, schema: ZodType<T>, empty: () => T): Js
|
|
|
51
51
|
export interface MapStore {
|
|
52
52
|
get(identifier: string): Promise<IssueMap | null>;
|
|
53
53
|
set(map: IssueMap): Promise<void>;
|
|
54
|
+
delete(identifier: string): Promise<void>;
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
/** Explore maps survive daemon restarts, so a finished explore run is never paid for twice. */
|
|
@@ -72,6 +73,12 @@ export function createMapStore(file: string): MapStore {
|
|
|
72
73
|
return { next: Object.fromEntries(kept.map((entry) => [entry.identifier, entry])), result: undefined };
|
|
73
74
|
});
|
|
74
75
|
},
|
|
76
|
+
delete(identifier) {
|
|
77
|
+
return store.update((current) => {
|
|
78
|
+
const { [identifier]: _removed, ...rest } = current;
|
|
79
|
+
return { next: rest, result: undefined };
|
|
80
|
+
});
|
|
81
|
+
},
|
|
75
82
|
};
|
|
76
83
|
}
|
|
77
84
|
|