@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.
Files changed (56) hide show
  1. package/OVERVIEW.md +20 -2
  2. package/README.md +91 -5
  3. package/client/access.tsx +66 -0
  4. package/client/browser.tsx +12 -9
  5. package/client/graph-camera.tsx +219 -0
  6. package/client/graph-labels.ts +147 -0
  7. package/client/launch-choices.ts +15 -4
  8. package/client/launch-guidance.tsx +86 -0
  9. package/client/launch-plan.ts +86 -16
  10. package/client/launch.tsx +19 -5
  11. package/client/live-agents.tsx +522 -0
  12. package/client/live-cursor.tsx +293 -0
  13. package/client/live-feed.tsx +151 -0
  14. package/client/live-graph.tsx +518 -0
  15. package/client/live-header.tsx +341 -0
  16. package/client/live-issues.tsx +345 -0
  17. package/client/live-map.tsx +345 -0
  18. package/client/live-panel.tsx +465 -0
  19. package/client/live-timeline.ts +376 -0
  20. package/client/pill.tsx +393 -0
  21. package/client/project-init.tsx +168 -0
  22. package/client/settings-access.tsx +56 -0
  23. package/client/settings-live.tsx +145 -0
  24. package/client/settings-projects.tsx +9 -0
  25. package/client/tool-buttons.tsx +68 -0
  26. package/client/web.ts +45 -0
  27. package/index.client.tsx +39 -1
  28. package/index.server.ts +29 -5
  29. package/package.json +1 -1
  30. package/server/agent-hooks.ts +37 -0
  31. package/server/agent-runs.ts +312 -0
  32. package/server/agent-tools.ts +240 -0
  33. package/server/handlers.ts +12 -1
  34. package/server/init.ts +65 -0
  35. package/server/links.ts +153 -0
  36. package/server/live.ts +360 -0
  37. package/server/mcp-http.ts +152 -0
  38. package/server/stores.ts +126 -0
  39. package/server/subagent-logs.ts +146 -0
  40. package/server/sync.ts +127 -0
  41. package/shared/activity.ts +312 -0
  42. package/shared/agent-hooks.ts +68 -0
  43. package/shared/agent-tools.ts +130 -0
  44. package/shared/graph-camera.ts +93 -0
  45. package/shared/graph-geometry.ts +122 -0
  46. package/shared/graph-model.ts +319 -0
  47. package/shared/links.ts +252 -0
  48. package/shared/live.ts +243 -0
  49. package/shared/map-model.ts +192 -0
  50. package/shared/project-setup.ts +55 -0
  51. package/shared/prompts.ts +65 -2
  52. package/shared/settings.ts +88 -1
  53. package/shared/shell-activity.ts +309 -0
  54. package/shared/shell-lexer.ts +180 -0
  55. package/shared/subagents.ts +106 -0
  56. package/shared/todo-sync.ts +158 -0
@@ -0,0 +1,153 @@
1
+ import { readdir, readFile, realpath, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import {
4
+ aliasesFrom,
5
+ type FileLink,
6
+ fileReferences,
7
+ GO_PACKAGE,
8
+ joinPath,
9
+ type LinkContext,
10
+ type LinkKind,
11
+ parseLooseJson,
12
+ type Reference,
13
+ } from "../shared/links";
14
+
15
+ // Reads the files on the Linear Live graph and resolves the links between them. Every path is
16
+ // checked against the agent's folder after symlinks resolve, so a link cannot reach outside it.
17
+
18
+ const MAX_BYTES = 512 * 1024;
19
+ const MAX_LINKS = 3000;
20
+ const MAX_PACKAGES = 100;
21
+ const CONTEXT_TTL_MS = 60_000;
22
+ const STRENGTH: Record<LinkKind, number> = { import: 3, link: 2, mention: 1 };
23
+
24
+ const contexts = new Map<string, { at: number; context: Promise<LinkContext> }>();
25
+ const parsed = new Map<string, Reference[]>();
26
+
27
+ function inside(root: string, resolved: string): boolean {
28
+ return resolved === root || resolved.startsWith(`${root}${path.sep}`);
29
+ }
30
+
31
+ async function resolveInside(root: string, relative: string): Promise<string | null> {
32
+ const resolved = await realpath(path.resolve(root, relative)).catch(() => null);
33
+ return resolved && inside(root, resolved) ? resolved : null;
34
+ }
35
+
36
+ async function readText(root: string, relative: string): Promise<string | null> {
37
+ const resolved = await resolveInside(root, relative);
38
+ if (!resolved) return null;
39
+ const info = await stat(resolved).catch(() => null);
40
+ if (!info?.isFile() || info.size > MAX_BYTES) return null;
41
+ const text = await readFile(resolved, "utf8").catch(() => null);
42
+ return text && !text.slice(0, 1024).includes("\u0000") ? text : null;
43
+ }
44
+
45
+ async function readJson(root: string, relative: string): Promise<unknown> {
46
+ const text = await readText(root, relative);
47
+ return text === null ? null : parseLooseJson(text);
48
+ }
49
+
50
+ // Workspace packages from package.json or pnpm-workspace.yaml, as "dir/*" or exact folders.
51
+ async function workspacePackages(root: string): Promise<LinkContext["packages"]> {
52
+ const manifest = (await readJson(root, "package.json")) as { workspaces?: string[] | { packages?: string[] } } | null;
53
+ const patterns = Array.isArray(manifest?.workspaces) ? manifest.workspaces : (manifest?.workspaces?.packages ?? []);
54
+ const pnpm = await readText(root, "pnpm-workspace.yaml");
55
+ if (pnpm) patterns.push(...[...pnpm.matchAll(/^\s*-\s*["']?([^"'\n#]+?)["']?\s*$/gm)].map((match) => match[1] ?? ""));
56
+ const dirs: string[] = [];
57
+ for (const pattern of patterns) {
58
+ const clean = pattern.replace(/\/+$/, "");
59
+ if (clean.startsWith("!") || clean.includes("**")) continue;
60
+ if (!clean.endsWith("/*")) {
61
+ dirs.push(clean);
62
+ continue;
63
+ }
64
+ const parent = clean.slice(0, -2);
65
+ const resolved = await resolveInside(root, parent);
66
+ const entries = resolved ? await readdir(resolved, { withFileTypes: true }).catch(() => []) : [];
67
+ for (const entry of entries) if (entry.isDirectory()) dirs.push(`${parent}/${entry.name}`);
68
+ }
69
+ const packages = new Map<string, { dir: string; entry: string | null }>();
70
+ for (const dir of dirs.slice(0, MAX_PACKAGES)) {
71
+ const pkg = (await readJson(root, `${dir}/package.json`)) as Record<string, unknown> | null;
72
+ if (!pkg || typeof pkg.name !== "string") continue;
73
+ const exported = pkg.exports as unknown;
74
+ const dot = typeof exported === "string" ? exported : (exported as Record<string, unknown> | undefined)?.["."];
75
+ const entry = [pkg.source, pkg.module, pkg.main, typeof dot === "string" ? dot : null].find((value) => typeof value === "string");
76
+ packages.set(pkg.name, { dir, entry: typeof entry === "string" ? joinPath(dir, entry) : null });
77
+ }
78
+ return packages;
79
+ }
80
+
81
+ async function buildContext(root: string): Promise<LinkContext> {
82
+ const aliases = [];
83
+ for (const file of ["tsconfig.json", "jsconfig.json"]) aliases.push(...aliasesFrom(await readJson(root, file)));
84
+ const goMod = await readText(root, "go.mod");
85
+ const goModule = goMod ? (/^module\s+(\S+)/m.exec(goMod)?.[1] ?? null) : null;
86
+ return { aliases, packages: await workspacePackages(root), goModule };
87
+ }
88
+
89
+ function linkContext(root: string): Promise<LinkContext> {
90
+ const cached = contexts.get(root);
91
+ if (cached && Date.now() - cached.at < CONTEXT_TTL_MS) return cached.context;
92
+ const context = buildContext(root);
93
+ contexts.set(root, { at: Date.now(), context });
94
+ return context;
95
+ }
96
+
97
+ async function references(root: string, relative: string, context: LinkContext): Promise<Reference[]> {
98
+ const resolved = await resolveInside(root, relative);
99
+ const info = resolved ? await stat(resolved).catch(() => null) : null;
100
+ if (!info?.isFile()) return [];
101
+ const key = `${root}\u0000${relative}\u0000${info.mtimeMs}\u0000${info.size}`;
102
+ const known = parsed.get(key);
103
+ if (known) return known;
104
+ const text = await readText(root, relative);
105
+ const found = text === null ? [] : fileReferences(relative, text, context);
106
+ if (parsed.size > 5000) parsed.clear();
107
+ parsed.set(key, found);
108
+ return found;
109
+ }
110
+
111
+ /** Links from each of the files to other files in the agent's folder. */
112
+ export async function fileLinks(cwd: string, files: readonly string[]): Promise<FileLink[]> {
113
+ const root = await realpath(cwd);
114
+ const context = await linkContext(root);
115
+ const exists = new Map<string, Promise<boolean>>();
116
+ const isFile = (relative: string) => {
117
+ let known = exists.get(relative);
118
+ if (!known) {
119
+ known = resolveInside(root, relative).then(async (resolved) => Boolean(resolved && (await stat(resolved)).isFile())).catch(() => false);
120
+ exists.set(relative, known);
121
+ }
122
+ return known;
123
+ };
124
+ const goFiles = async (dir: string) => {
125
+ const resolved = await resolveInside(root, dir);
126
+ const entries = resolved ? await readdir(resolved, { withFileTypes: true }).catch(() => []) : [];
127
+ return entries.filter((entry) => entry.isFile() && entry.name.endsWith(".go")).map((entry) => `${dir}/${entry.name}`);
128
+ };
129
+ const best = new Map<string, FileLink>();
130
+ for (const from of new Set(files)) {
131
+ for (const reference of await references(root, from, context)) {
132
+ let targets: string[] = [];
133
+ for (const candidate of reference.candidates) {
134
+ if (candidate.endsWith(GO_PACKAGE)) {
135
+ targets = await goFiles(candidate.slice(0, -GO_PACKAGE.length));
136
+ break;
137
+ }
138
+ if (await isFile(candidate)) {
139
+ targets = [candidate];
140
+ break;
141
+ }
142
+ }
143
+ for (const to of targets) {
144
+ if (to === from) continue;
145
+ const id = `${from}>${to}`;
146
+ const known = best.get(id);
147
+ if (!known || STRENGTH[reference.kind] > STRENGTH[known.kind]) best.set(id, { from, to, kind: reference.kind });
148
+ }
149
+ if (best.size >= MAX_LINKS) return [...best.values()];
150
+ }
151
+ }
152
+ return [...best.values()];
153
+ }
package/server/live.ts ADDED
@@ -0,0 +1,360 @@
1
+ import { readdir, realpath } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import type { PluginServerContext } from "@getpaseo/plugin/server";
4
+ import { z } from "zod";
5
+ import { relativePath } from "../shared/activity";
6
+ import { type IssueDetail, ISSUE_CARD_KIND } from "../shared/linear";
7
+ import {
8
+ ExploreResultSchema,
9
+ exploreRpc,
10
+ type IssueMap,
11
+ type Job,
12
+ type LaunchInput,
13
+ launchAfterExploreRpc,
14
+ clearMapRpc,
15
+ linksRpc,
16
+ listFilesRpc,
17
+ subagentLogsRpc,
18
+ liveMapRpc,
19
+ MAX_DIR_ENTRIES,
20
+ MAX_EXPLORE_FILES,
21
+ } from "../shared/live";
22
+ import { AGENT_LABELS, issueSnapshot } from "../shared/prompts";
23
+ import { type LinearSettings, projectEnabled } from "../shared/settings";
24
+ import {
25
+ createJobStore,
26
+ finishInternalRun,
27
+ type Paseo,
28
+ READ_ONLY_RULES,
29
+ startInternalRun,
30
+ } from "./agent-runs";
31
+ import type { LinearAccess } from "./handlers";
32
+ import { fileLinks } from "./links";
33
+ import { subagentLogs } from "./subagent-logs";
34
+ import type { LaunchQueue, MapStore } from "./stores";
35
+
36
+ const EXPLORE_TIMEOUT_MS = 20 * 60_000;
37
+ const RECOVER_INTERVAL_MS = 30_000;
38
+ const EXPLORE_TITLE = /^Explore ([A-Za-z][A-Za-z0-9]*-\d+)$/;
39
+ const SKIPPED_ENTRIES = new Set([".git", "node_modules", ".DS_Store"]);
40
+
41
+ export interface AgentInfo {
42
+ id: string;
43
+ cwd: string;
44
+ workspaceId: string | null;
45
+ /** Paseo project of the agent's workspace, when the workspace is known. */
46
+ projectId: string | null;
47
+ provider: string;
48
+ /** The provider's session, such as Claude's session id. */
49
+ sessionId: string | null;
50
+ labels: Record<string, string>;
51
+ }
52
+
53
+ export async function agentInfo(paseo: Paseo, agentId: string): Promise<AgentInfo> {
54
+ const handle = paseo.agents.ref(agentId);
55
+ await handle.refresh();
56
+ const agent = handle.current();
57
+ if (!agent) throw new Error("The agent was not found");
58
+ const workspaceId = agent.workspaceId ?? null;
59
+ const workspace = workspaceId
60
+ ? await paseo.workspaces
61
+ .ref(workspaceId)
62
+ .refresh()
63
+ .catch(() => null)
64
+ : null;
65
+ return {
66
+ id: agent.id,
67
+ cwd: agent.cwd,
68
+ workspaceId,
69
+ projectId: workspace?.projectId ?? null,
70
+ provider: agent.provider,
71
+ sessionId: agent.runtimeInfo?.sessionId ?? null,
72
+ labels: agent.labels ?? {},
73
+ };
74
+ }
75
+
76
+ // The client names directories, never a root: the root is the agent's own folder, and a
77
+ // directory that resolves outside it, through ".." or a symlink, is skipped.
78
+ export async function listDirectories(cwd: string, dirs: readonly string[]) {
79
+ const root = await realpath(cwd);
80
+ const results: { dir: string; files: string[]; truncated: boolean }[] = [];
81
+ for (const dir of new Set(dirs)) {
82
+ const relative = dir === "" ? "" : relativePath(dir, root);
83
+ if (relative === null) continue;
84
+ const resolved = await realpath(path.resolve(root, relative)).catch(() => null);
85
+ if (!resolved || (resolved !== root && !resolved.startsWith(`${root}${path.sep}`))) continue;
86
+ const entries = await readdir(resolved, { withFileTypes: true }).catch(() => []);
87
+ const files = entries
88
+ .filter((entry) => entry.isFile() && !SKIPPED_ENTRIES.has(entry.name))
89
+ .map((entry) => entry.name)
90
+ .sort((a, b) => a.localeCompare(b));
91
+ results.push({
92
+ dir,
93
+ files: files.slice(0, MAX_DIR_ENTRIES),
94
+ truncated: files.length > MAX_DIR_ENTRIES,
95
+ });
96
+ }
97
+ return results;
98
+ }
99
+
100
+ export function explorePrompt(issue: IssueDetail): string {
101
+ const keys = [issue.identifier, ...issue.children.map((child) => child.identifier)];
102
+ return [
103
+ `Map this repository for Linear issue ${issue.identifier}: ${issue.title}`,
104
+ "",
105
+ ...READ_ONLY_RULES,
106
+ "",
107
+ issueSnapshot(issue, { includeComments: false }),
108
+ "",
109
+ "List the files an engineer will most likely read or change for this issue and each sub-issue.",
110
+ `Include files that will probably be created, at their expected path. List at most ${MAX_EXPLORE_FILES} files.`,
111
+ `Set "issue" to the sub-issue key the file belongs to, or ${issue.identifier} for the whole issue.`,
112
+ `Valid keys: ${keys.join(", ")}.`,
113
+ "Reply with only a JSON object in a ```json block, with repo-relative paths:",
114
+ `{"files":[{"path":"src/example.ts","issue":"${keys[1] ?? keys[0]}"}]}`,
115
+ ].join("\n");
116
+ }
117
+
118
+ export function toIssueMap(
119
+ issue: IssueDetail,
120
+ cwd: string,
121
+ result: z.infer<typeof ExploreResultSchema>,
122
+ now = new Date(),
123
+ ): IssueMap {
124
+ const keys = new Set([issue.identifier, ...issue.children.map((child) => child.identifier)]);
125
+ const seen = new Set<string>();
126
+ const files: IssueMap["files"] = [];
127
+ for (const entry of result.files) {
128
+ const filePath = relativePath(entry.path, cwd);
129
+ if (!filePath || seen.has(filePath)) continue;
130
+ seen.add(filePath);
131
+ const key = entry.issue.trim().toUpperCase();
132
+ files.push({ path: filePath, issue: keys.has(key) ? key : issue.identifier });
133
+ }
134
+ return { identifier: issue.identifier, source: "explore", files, createdAt: now.toISOString() };
135
+ }
136
+
137
+ export interface LiveDependencies {
138
+ access: LinearAccess;
139
+ maps: MapStore;
140
+ launches: LaunchQueue;
141
+ readSettings(): Promise<LinearSettings | null>;
142
+ }
143
+
144
+ type WorkspaceHandle = Awaited<ReturnType<Paseo["workspaces"]["open"]>>;
145
+
146
+ /** Starts the implementing agent and attaches the issue card to its timeline. */
147
+ export async function startLaunch(workspace: WorkspaceHandle, launch: LaunchInput) {
148
+ const agent = await workspace.agents.create(launch.agent);
149
+ await agent.timeline
150
+ .append({
151
+ type: "plugin",
152
+ id: `linear-issue-${launch.card.identifier}`,
153
+ kind: ISSUE_CARD_KIND,
154
+ version: 1,
155
+ data: launch.card,
156
+ })
157
+ .catch((error: unknown) => console.error("Linear issue card was not attached", error));
158
+ return agent.id;
159
+ }
160
+
161
+ export function registerLive(server: PluginServerContext, dependencies: LiveDependencies) {
162
+ const { access, maps, launches, readSettings } = dependencies;
163
+ const jobs = createJobStore<IssueMap>();
164
+ const recovering = new Map<string, Promise<Job | null>>();
165
+ const recoveredAt = new Map<string, number>();
166
+
167
+ // Called whenever an explore run ends, by any path. Claiming first means a launch that two
168
+ // paths see still starts one agent.
169
+ async function startWaiting(paseo: Paseo, identifier: string) {
170
+ for (const launch of await launches.claim(identifier)) {
171
+ await startLaunch(paseo.workspaces.ref(launch.workspaceId), launch).catch((error: unknown) =>
172
+ console.error(`Linear agent for ${identifier} did not start after exploring`, error),
173
+ );
174
+ }
175
+ }
176
+
177
+ function track(paseo: Paseo, job: Job) {
178
+ void jobs.wait(job.id).then(() => startWaiting(paseo, job.id));
179
+ return job;
180
+ }
181
+
182
+ function saveMap(issue: IssueDetail, cwd: string) {
183
+ return async (value: z.infer<typeof ExploreResultSchema>) => {
184
+ const map = toIssueMap(issue, cwd, value);
185
+ await maps.set(map);
186
+ return map;
187
+ };
188
+ }
189
+
190
+ // A plugin reload or daemon restart drops the job but not the explore agent. Finds that
191
+ // agent by its label and finishes the run, or starts waiting launches when there is none.
192
+ async function adopt(paseo: Paseo, identifier: string): Promise<Job | null> {
193
+ const { entries } = await paseo.agents.list({
194
+ filter: { labels: { [AGENT_LABELS.explore]: identifier } },
195
+ page: { limit: 10 },
196
+ });
197
+ const orphan = entries.map((entry) => entry.agent).find((agent) => !agent.archivedAt);
198
+ if (!orphan) {
199
+ await startWaiting(paseo, identifier);
200
+ return null;
201
+ }
202
+ const job = await jobs.start(identifier, async () => {
203
+ const { linear } = await access.connect(orphan.labels?.[AGENT_LABELS.project] ?? null);
204
+ const issue = await linear.getIssue(identifier);
205
+ const handle = paseo.agents.ref(orphan.id);
206
+ const result = finishInternalRun(handle, orphan.cwd, ExploreResultSchema, EXPLORE_TIMEOUT_MS);
207
+ return { agentId: orphan.id, result: result.then(saveMap(issue, orphan.cwd)) };
208
+ });
209
+ return track(paseo, job);
210
+ }
211
+
212
+ function recover(paseo: Paseo, identifier: string): Promise<Job | null> {
213
+ const existing = jobs.get(identifier)?.job;
214
+ if (existing?.status === "running") return Promise.resolve(existing);
215
+ const inFlight = recovering.get(identifier);
216
+ if (inFlight) return inFlight;
217
+ recoveredAt.set(identifier, Date.now());
218
+ const run = adopt(paseo, identifier).finally(() => recovering.delete(identifier));
219
+ recovering.set(identifier, run);
220
+ return run;
221
+ }
222
+
223
+ server.handle(listFilesRpc, async ({ agentId, dirs }, { paseo }) => {
224
+ const agent = await agentInfo(paseo, agentId);
225
+ return { dirs: await listDirectories(agent.cwd, dirs) };
226
+ });
227
+
228
+ server.handle(linksRpc, async ({ agentId, files }, { paseo }) => {
229
+ const agent = await agentInfo(paseo, agentId);
230
+ return { links: await fileLinks(agent.cwd, files) };
231
+ });
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
+ });
248
+ server.handle(liveMapRpc, async ({ identifier }, { paseo }) => {
249
+ const map = await maps.get(identifier);
250
+ let job = jobs.get(identifier)?.job ?? null;
251
+ const checked = recoveredAt.get(identifier) ?? 0;
252
+ if (!map && !job && Date.now() - checked > RECOVER_INTERVAL_MS) {
253
+ job = await recover(paseo, identifier).catch(() => null);
254
+ }
255
+ return { map: map ?? (await maps.get(identifier)), job };
256
+ });
257
+
258
+ // Finishes runs whose job a reload dropped, as soon as the agent's turn ends.
259
+ server.on("agent.turn_ended", async (event, { paseo }) => {
260
+ const identifier = EXPLORE_TITLE.exec(event.agent.title ?? "")?.[1];
261
+ if (!identifier || jobs.get(identifier)?.job.status === "running") return;
262
+ await recover(paseo, identifier).catch((error: unknown) =>
263
+ console.error(`Linear explore run for ${identifier} was not recovered`, error),
264
+ );
265
+ });
266
+
267
+ interface ExploreTarget {
268
+ identifier: string;
269
+ keyScope: string | null;
270
+ cwd: string;
271
+ /** Opened only when a new run starts. */
272
+ workspace: () => Promise<WorkspaceHandle>;
273
+ provider: string;
274
+ effort: string;
275
+ force: boolean;
276
+ }
277
+
278
+ // One explore run per issue: a running job is shared, and a saved map is reused.
279
+ async function ensureExplore(paseo: Paseo, target: ExploreTarget): Promise<Job> {
280
+ const { identifier } = target;
281
+ const existing = jobs.get(identifier)?.job;
282
+ if (existing?.status === "running" || (existing?.status === "done" && !target.force)) {
283
+ return existing;
284
+ }
285
+ const stored = target.force ? null : await maps.get(identifier);
286
+ if (stored) {
287
+ return { id: identifier, status: "done", agentId: null, error: null, startedAt: stored.createdAt };
288
+ }
289
+ const adopted = target.force ? null : await recover(paseo, identifier).catch(() => null);
290
+ if (adopted?.status === "running") return adopted;
291
+ const job = await jobs.start(identifier, async () => {
292
+ const { linear } = await access.connect(target.keyScope);
293
+ const issue = await linear.getIssue(identifier);
294
+ const labels: Record<string, string> = { [AGENT_LABELS.explore]: identifier };
295
+ if (target.keyScope) labels[AGENT_LABELS.project] = target.keyScope;
296
+ const run = await startInternalRun({
297
+ paseo,
298
+ workspace: await target.workspace(),
299
+ cwd: target.cwd,
300
+ provider: target.provider,
301
+ effort: target.effort,
302
+ title: `Explore ${identifier}`,
303
+ prompt: explorePrompt(issue),
304
+ labels,
305
+ schema: ExploreResultSchema,
306
+ timeoutMs: EXPLORE_TIMEOUT_MS,
307
+ });
308
+ return { agentId: run.agentId, result: run.result.then(saveMap(issue, target.cwd)) };
309
+ });
310
+ return track(paseo, job);
311
+ }
312
+ async function requireEnabled(projectId: string | null) {
313
+ const settings = await readSettings();
314
+ if (settings && !projectEnabled(settings.access, projectId)) {
315
+ throw new Error("Linear is off for this project");
316
+ }
317
+ return settings;
318
+ }
319
+
320
+ server.handle(exploreRpc, async ({ agentId, force }, { paseo }) => {
321
+ const agent = await agentInfo(paseo, agentId);
322
+ const identifier = agent.labels[AGENT_LABELS.issue];
323
+ if (!identifier) throw new Error("This agent is not linked to a Linear issue");
324
+ const settings = await requireEnabled(agent.projectId);
325
+ const { workspaceId, cwd } = agent;
326
+ const job = await ensureExplore(paseo, {
327
+ identifier,
328
+ keyScope: agent.labels[AGENT_LABELS.project] ?? null,
329
+ cwd,
330
+ workspace: async () =>
331
+ workspaceId ? paseo.workspaces.ref(workspaceId) : paseo.workspaces.open(cwd),
332
+ provider: settings?.live.exploreProvider || agent.provider,
333
+ effort: settings?.live.exploreEffort ?? "",
334
+ force,
335
+ });
336
+ return { job };
337
+ });
338
+
339
+ server.handle(launchAfterExploreRpc, async (input, { paseo }) => {
340
+ const workspace = paseo.workspaces.ref(input.workspaceId);
341
+ const info = await workspace.refresh();
342
+ if (!info) throw new Error("The workspace was not found");
343
+ const settings = await requireEnabled(info.projectId);
344
+ const job = await ensureExplore(paseo, {
345
+ identifier: input.identifier,
346
+ keyScope: input.keyScope,
347
+ cwd: info.workspaceDirectory ?? info.projectRootPath,
348
+ workspace: async () => workspace,
349
+ provider: settings?.live.exploreProvider || input.agent.config.provider,
350
+ effort: settings?.live.exploreEffort ?? "",
351
+ force: false,
352
+ });
353
+ // A failed or timed-out explore run still starts the agent; the map falls back to ticket text.
354
+ if (job.status !== "running") return { agentId: await startLaunch(workspace, input), job };
355
+ await launches.add(input);
356
+ // The run may have ended while the launch was saved; claiming makes a second start a no-op.
357
+ if (jobs.get(job.id)?.job.status !== "running") await startWaiting(paseo, input.identifier);
358
+ return { agentId: null, job };
359
+ });
360
+ }
@@ -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
+ }