@yannelli/paseo-linear-plugin 1.0.0 → 1.1.0-alpha.0
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 +32 -2
- package/README.md +138 -11
- package/client/access.tsx +67 -0
- package/client/agent-options.ts +12 -2
- package/client/agent-preferences.ts +112 -0
- package/client/browser.tsx +12 -9
- package/client/compat.ts +23 -5
- package/client/connect.tsx +3 -2
- package/client/launch-assignments.tsx +182 -0
- package/client/launch-choices.ts +23 -6
- package/client/launch-guidance.tsx +103 -0
- package/client/launch-plan.ts +166 -42
- package/client/launch.tsx +55 -9
- package/client/live-agents.tsx +446 -0
- package/client/live-feed.tsx +151 -0
- package/client/live-header.tsx +310 -0
- package/client/live-issues.tsx +345 -0
- package/client/live-map.tsx +351 -0
- package/client/live-panel.tsx +392 -0
- package/client/live-timeline.ts +379 -0
- package/client/pill.tsx +394 -0
- package/client/plugin-icon.tsx +75 -0
- package/client/project-init.tsx +192 -0
- package/client/project-knowledge.tsx +87 -0
- package/client/settings-access.tsx +56 -0
- package/client/settings-agents.tsx +113 -0
- package/client/settings-fields.tsx +11 -3
- package/client/settings-icon.tsx +277 -0
- package/client/settings-live.tsx +128 -0
- package/client/settings-overrides.tsx +148 -0
- package/client/settings-projects.tsx +26 -4
- package/client/settings-prompts.tsx +3 -3
- package/client/timeline-card.tsx +2 -1
- package/client/tool-buttons.tsx +68 -0
- package/client/ui.tsx +16 -7
- package/client/web.ts +81 -0
- package/index.client.tsx +160 -51
- package/index.server.ts +32 -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 +276 -0
- package/server/handlers.ts +12 -1
- package/server/init.ts +89 -0
- package/server/inspect.ts +392 -0
- package/server/knowledge.ts +157 -0
- package/server/live.ts +371 -0
- package/server/mcp-http.ts +156 -0
- package/server/stores.ts +126 -0
- package/server/subagent-logs.ts +146 -0
- package/server/sync.ts +133 -0
- package/shared/activity.ts +268 -0
- package/shared/agent-hooks.ts +68 -0
- package/shared/agent-tools.ts +157 -0
- package/shared/custom-icon.ts +188 -0
- package/shared/knowledge.ts +255 -0
- package/shared/live.ts +248 -0
- package/shared/map-model.ts +240 -0
- package/shared/project-setup.ts +56 -0
- package/shared/prompts.ts +109 -2
- package/shared/settings.ts +145 -3
- 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
package/server/live.ts
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
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 { knowledgeLines, liveProjectRpc, type ProjectKnowledge, resolvePaths, toAreaRef } from "../shared/knowledge";
|
|
7
|
+
import { type IssueDetail, ISSUE_CARD_KIND } from "../shared/linear";
|
|
8
|
+
import {
|
|
9
|
+
ExploreResultSchema,
|
|
10
|
+
exploreRpc,
|
|
11
|
+
type IssueMap,
|
|
12
|
+
type Job,
|
|
13
|
+
type LaunchInput,
|
|
14
|
+
launchAfterExploreRpc,
|
|
15
|
+
clearMapRpc,
|
|
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 { type KnowledgeService, knowledgeFor } from "./knowledge";
|
|
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, knowledge: ProjectKnowledge | null = null): string {
|
|
101
|
+
const keys = [issue.identifier, ...issue.children.map((child) => child.identifier)];
|
|
102
|
+
const map = knowledgeLines(knowledge);
|
|
103
|
+
return [
|
|
104
|
+
`Map this repository for Linear issue ${issue.identifier}: ${issue.title}`,
|
|
105
|
+
"",
|
|
106
|
+
...READ_ONLY_RULES,
|
|
107
|
+
"",
|
|
108
|
+
issueSnapshot(issue, { includeComments: false }),
|
|
109
|
+
"",
|
|
110
|
+
...(map.length > 0 ? [...map, ""] : []),
|
|
111
|
+
"List the files an engineer will most likely read or change for this issue and each sub-issue.",
|
|
112
|
+
`Include files that will probably be created, at their expected path. List at most ${MAX_EXPLORE_FILES} files.`,
|
|
113
|
+
`Set "issue" to the sub-issue key the file belongs to, or ${issue.identifier} for the whole issue.`,
|
|
114
|
+
`Valid keys: ${keys.join(", ")}.`,
|
|
115
|
+
"Reply with only a JSON object in a ```json block, with repo-relative paths:",
|
|
116
|
+
`{"files":[{"path":"src/example.ts","issue":"${keys[1] ?? keys[0]}"}]}`,
|
|
117
|
+
].join("\n");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function toIssueMap(
|
|
121
|
+
issue: IssueDetail,
|
|
122
|
+
cwd: string,
|
|
123
|
+
result: z.infer<typeof ExploreResultSchema>,
|
|
124
|
+
now = new Date(),
|
|
125
|
+
): IssueMap {
|
|
126
|
+
const keys = new Set([issue.identifier, ...issue.children.map((child) => child.identifier)]);
|
|
127
|
+
const seen = new Set<string>();
|
|
128
|
+
const files: IssueMap["files"] = [];
|
|
129
|
+
for (const entry of result.files) {
|
|
130
|
+
const filePath = relativePath(entry.path, cwd);
|
|
131
|
+
if (!filePath || seen.has(filePath)) continue;
|
|
132
|
+
seen.add(filePath);
|
|
133
|
+
const key = entry.issue.trim().toUpperCase();
|
|
134
|
+
files.push({ path: filePath, issue: keys.has(key) ? key : issue.identifier });
|
|
135
|
+
}
|
|
136
|
+
return { identifier: issue.identifier, source: "explore", files, createdAt: now.toISOString() };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface LiveDependencies {
|
|
140
|
+
access: LinearAccess;
|
|
141
|
+
maps: MapStore;
|
|
142
|
+
launches: LaunchQueue;
|
|
143
|
+
knowledge: KnowledgeService;
|
|
144
|
+
readSettings(): Promise<LinearSettings | null>;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
type WorkspaceHandle = Awaited<ReturnType<Paseo["workspaces"]["open"]>>;
|
|
148
|
+
|
|
149
|
+
/** Starts the implementing agent and attaches the issue card to its timeline. */
|
|
150
|
+
export async function startLaunch(workspace: WorkspaceHandle, launch: LaunchInput) {
|
|
151
|
+
const agent = await workspace.agents.create(launch.agent);
|
|
152
|
+
await agent.timeline
|
|
153
|
+
.append({
|
|
154
|
+
type: "plugin",
|
|
155
|
+
id: `linear-issue-${launch.card.identifier}`,
|
|
156
|
+
kind: ISSUE_CARD_KIND,
|
|
157
|
+
version: 1,
|
|
158
|
+
data: launch.card,
|
|
159
|
+
})
|
|
160
|
+
.catch((error: unknown) => console.error("Linear issue card was not attached", error));
|
|
161
|
+
return agent.id;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function registerLive(server: PluginServerContext, dependencies: LiveDependencies) {
|
|
165
|
+
const { access, maps, launches, knowledge, readSettings } = dependencies;
|
|
166
|
+
const jobs = createJobStore<IssueMap>();
|
|
167
|
+
const recovering = new Map<string, Promise<Job | null>>();
|
|
168
|
+
const recoveredAt = new Map<string, number>();
|
|
169
|
+
|
|
170
|
+
// Called whenever an explore run ends, by any path. Claiming first means a launch that two
|
|
171
|
+
// paths see still starts one agent.
|
|
172
|
+
async function startWaiting(paseo: Paseo, identifier: string) {
|
|
173
|
+
for (const launch of await launches.claim(identifier)) {
|
|
174
|
+
await startLaunch(paseo.workspaces.ref(launch.workspaceId), launch).catch((error: unknown) =>
|
|
175
|
+
console.error(`Linear agent for ${identifier} did not start after exploring`, error),
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function track(paseo: Paseo, job: Job) {
|
|
181
|
+
void jobs.wait(job.id).then(() => startWaiting(paseo, job.id));
|
|
182
|
+
return job;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function saveMap(issue: IssueDetail, cwd: string) {
|
|
186
|
+
return async (value: z.infer<typeof ExploreResultSchema>) => {
|
|
187
|
+
const map = toIssueMap(issue, cwd, value);
|
|
188
|
+
await maps.set(map);
|
|
189
|
+
return map;
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// A plugin reload or daemon restart drops the job but not the explore agent. Finds that
|
|
194
|
+
// agent by its label and finishes the run, or starts waiting launches when there is none.
|
|
195
|
+
async function adopt(paseo: Paseo, identifier: string): Promise<Job | null> {
|
|
196
|
+
const { entries } = await paseo.agents.list({
|
|
197
|
+
filter: { labels: { [AGENT_LABELS.explore]: identifier } },
|
|
198
|
+
page: { limit: 10 },
|
|
199
|
+
});
|
|
200
|
+
const orphan = entries.map((entry) => entry.agent).find((agent) => !agent.archivedAt);
|
|
201
|
+
if (!orphan) {
|
|
202
|
+
await startWaiting(paseo, identifier);
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
const job = await jobs.start(identifier, async () => {
|
|
206
|
+
const { linear } = await access.connect(orphan.labels?.[AGENT_LABELS.project] ?? null);
|
|
207
|
+
const issue = await linear.getIssue(identifier);
|
|
208
|
+
const handle = paseo.agents.ref(orphan.id);
|
|
209
|
+
const result = finishInternalRun(handle, orphan.cwd, ExploreResultSchema, EXPLORE_TIMEOUT_MS);
|
|
210
|
+
return { agentId: orphan.id, result: result.then(saveMap(issue, orphan.cwd)) };
|
|
211
|
+
});
|
|
212
|
+
return track(paseo, job);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function recover(paseo: Paseo, identifier: string): Promise<Job | null> {
|
|
216
|
+
const existing = jobs.get(identifier)?.job;
|
|
217
|
+
if (existing?.status === "running") return Promise.resolve(existing);
|
|
218
|
+
const inFlight = recovering.get(identifier);
|
|
219
|
+
if (inFlight) return inFlight;
|
|
220
|
+
recoveredAt.set(identifier, Date.now());
|
|
221
|
+
const run = adopt(paseo, identifier).finally(() => recovering.delete(identifier));
|
|
222
|
+
recovering.set(identifier, run);
|
|
223
|
+
return run;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
server.handle(listFilesRpc, async ({ agentId, dirs }, { paseo }) => {
|
|
227
|
+
const agent = await agentInfo(paseo, agentId);
|
|
228
|
+
return { dirs: await listDirectories(agent.cwd, dirs) };
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
server.handle(liveProjectRpc, async ({ agentId, paths }, { paseo }) => {
|
|
232
|
+
const agent = await agentInfo(paseo, agentId);
|
|
233
|
+
const known = await knowledgeFor(paseo, knowledge, agent.projectId);
|
|
234
|
+
if (!known) return { areas: [], resolved: [] };
|
|
235
|
+
const resolved = [...resolvePaths(paths, known.files)].map(([from, to]) => ({ from, to }));
|
|
236
|
+
return { areas: known.areas.map(toAreaRef), resolved };
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
server.handle(clearMapRpc, async ({ agentId }, { paseo }) => {
|
|
240
|
+
const agent = await agentInfo(paseo, agentId);
|
|
241
|
+
const identifier = agent.labels[AGENT_LABELS.issue];
|
|
242
|
+
if (!identifier) throw new Error("This agent is not linked to a Linear issue");
|
|
243
|
+
if (jobs.get(identifier)?.job.status === "running") throw new Error("The explore agent is still mapping this issue");
|
|
244
|
+
await maps.delete(identifier);
|
|
245
|
+
jobs.forget(identifier);
|
|
246
|
+
return { cleared: true };
|
|
247
|
+
});
|
|
248
|
+
server.handle(subagentLogsRpc, async ({ agentId }, { paseo }) => {
|
|
249
|
+
const agent = await agentInfo(paseo, agentId);
|
|
250
|
+
// Only Claude keeps subagent transcripts this plugin can read.
|
|
251
|
+
if (agent.provider.split("/")[0] !== "claude" || !agent.sessionId) return { runs: [] };
|
|
252
|
+
return { runs: await subagentLogs(agent.sessionId, agent.cwd) };
|
|
253
|
+
});
|
|
254
|
+
server.handle(liveMapRpc, async ({ identifier }, { paseo }) => {
|
|
255
|
+
const map = await maps.get(identifier);
|
|
256
|
+
let job = jobs.get(identifier)?.job ?? null;
|
|
257
|
+
const checked = recoveredAt.get(identifier) ?? 0;
|
|
258
|
+
if (!map && !job && Date.now() - checked > RECOVER_INTERVAL_MS) {
|
|
259
|
+
job = await recover(paseo, identifier).catch(() => null);
|
|
260
|
+
}
|
|
261
|
+
return { map: map ?? (await maps.get(identifier)), job };
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// Finishes runs whose job a reload dropped, as soon as the agent's turn ends.
|
|
265
|
+
server.on("agent.turn_ended", async (event, { paseo }) => {
|
|
266
|
+
const identifier = EXPLORE_TITLE.exec(event.agent.title ?? "")?.[1];
|
|
267
|
+
if (!identifier || jobs.get(identifier)?.job.status === "running") return;
|
|
268
|
+
await recover(paseo, identifier).catch((error: unknown) =>
|
|
269
|
+
console.error(`Linear explore run for ${identifier} was not recovered`, error),
|
|
270
|
+
);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
interface ExploreTarget {
|
|
274
|
+
identifier: string;
|
|
275
|
+
keyScope: string | null;
|
|
276
|
+
/** Paseo project whose saved knowledge goes into the prompt. */
|
|
277
|
+
projectId: string | null;
|
|
278
|
+
cwd: string;
|
|
279
|
+
/** Opened only when a new run starts. */
|
|
280
|
+
workspace: () => Promise<WorkspaceHandle>;
|
|
281
|
+
provider: string;
|
|
282
|
+
effort: string;
|
|
283
|
+
force: boolean;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// One explore run per issue: a running job is shared, and a saved map is reused.
|
|
287
|
+
async function ensureExplore(paseo: Paseo, target: ExploreTarget): Promise<Job> {
|
|
288
|
+
const { identifier } = target;
|
|
289
|
+
const existing = jobs.get(identifier)?.job;
|
|
290
|
+
if (existing?.status === "running" || (existing?.status === "done" && !target.force)) {
|
|
291
|
+
return existing;
|
|
292
|
+
}
|
|
293
|
+
const stored = target.force ? null : await maps.get(identifier);
|
|
294
|
+
if (stored) {
|
|
295
|
+
return { id: identifier, status: "done", agentId: null, error: null, startedAt: stored.createdAt };
|
|
296
|
+
}
|
|
297
|
+
const adopted = target.force ? null : await recover(paseo, identifier).catch(() => null);
|
|
298
|
+
if (adopted?.status === "running") return adopted;
|
|
299
|
+
const job = await jobs.start(identifier, async () => {
|
|
300
|
+
const { linear } = await access.connect(target.keyScope);
|
|
301
|
+
const issue = await linear.getIssue(identifier);
|
|
302
|
+
const labels: Record<string, string> = { [AGENT_LABELS.explore]: identifier };
|
|
303
|
+
if (target.keyScope) labels[AGENT_LABELS.project] = target.keyScope;
|
|
304
|
+
const known = await knowledgeFor(paseo, knowledge, target.projectId);
|
|
305
|
+
const run = await startInternalRun({
|
|
306
|
+
paseo,
|
|
307
|
+
workspace: await target.workspace(),
|
|
308
|
+
cwd: target.cwd,
|
|
309
|
+
provider: target.provider,
|
|
310
|
+
effort: target.effort,
|
|
311
|
+
title: `Explore ${identifier}`,
|
|
312
|
+
prompt: explorePrompt(issue, known),
|
|
313
|
+
labels,
|
|
314
|
+
schema: ExploreResultSchema,
|
|
315
|
+
timeoutMs: EXPLORE_TIMEOUT_MS,
|
|
316
|
+
});
|
|
317
|
+
return { agentId: run.agentId, result: run.result.then(saveMap(issue, target.cwd)) };
|
|
318
|
+
});
|
|
319
|
+
return track(paseo, job);
|
|
320
|
+
}
|
|
321
|
+
async function requireEnabled(projectId: string | null) {
|
|
322
|
+
const settings = await readSettings();
|
|
323
|
+
if (settings && !projectEnabled(settings.access, projectId)) {
|
|
324
|
+
throw new Error("Linear is off for this project");
|
|
325
|
+
}
|
|
326
|
+
return settings;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
server.handle(exploreRpc, async ({ agentId, force }, { paseo }) => {
|
|
330
|
+
const agent = await agentInfo(paseo, agentId);
|
|
331
|
+
const identifier = agent.labels[AGENT_LABELS.issue];
|
|
332
|
+
if (!identifier) throw new Error("This agent is not linked to a Linear issue");
|
|
333
|
+
const settings = await requireEnabled(agent.projectId);
|
|
334
|
+
const { workspaceId, cwd } = agent;
|
|
335
|
+
const job = await ensureExplore(paseo, {
|
|
336
|
+
identifier,
|
|
337
|
+
keyScope: agent.labels[AGENT_LABELS.project] ?? null,
|
|
338
|
+
projectId: agent.projectId,
|
|
339
|
+
cwd,
|
|
340
|
+
workspace: async () =>
|
|
341
|
+
workspaceId ? paseo.workspaces.ref(workspaceId) : paseo.workspaces.open(cwd),
|
|
342
|
+
provider: settings?.live.exploreProvider || agent.provider,
|
|
343
|
+
effort: settings?.live.exploreEffort ?? "",
|
|
344
|
+
force,
|
|
345
|
+
});
|
|
346
|
+
return { job };
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
server.handle(launchAfterExploreRpc, async (input, { paseo }) => {
|
|
350
|
+
const workspace = paseo.workspaces.ref(input.workspaceId);
|
|
351
|
+
const info = await workspace.refresh();
|
|
352
|
+
if (!info) throw new Error("The workspace was not found");
|
|
353
|
+
const settings = await requireEnabled(info.projectId);
|
|
354
|
+
const job = await ensureExplore(paseo, {
|
|
355
|
+
identifier: input.identifier,
|
|
356
|
+
keyScope: input.keyScope,
|
|
357
|
+
projectId: info.projectId,
|
|
358
|
+
cwd: info.workspaceDirectory ?? info.projectRootPath,
|
|
359
|
+
workspace: async () => workspace,
|
|
360
|
+
provider: settings?.live.exploreProvider || input.agent.config.provider,
|
|
361
|
+
effort: settings?.live.exploreEffort ?? "",
|
|
362
|
+
force: false,
|
|
363
|
+
});
|
|
364
|
+
// A failed or timed-out explore run still starts the agent; the map falls back to ticket text.
|
|
365
|
+
if (job.status !== "running") return { agentId: await startLaunch(workspace, input), job };
|
|
366
|
+
await launches.add(input);
|
|
367
|
+
// The run may have ended while the launch was saved; claiming makes a second start a no-op.
|
|
368
|
+
if (jobs.get(job.id)?.job.status !== "running") await startWaiting(paseo, input.identifier);
|
|
369
|
+
return { agentId: null, job };
|
|
370
|
+
});
|
|
371
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
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
|
+
/** Why the caller gets no tools at all, or null. The tool list is then empty. */
|
|
31
|
+
closed?(caller: C): string | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
|
|
35
|
+
const MAX_BODY = 1_000_000;
|
|
36
|
+
|
|
37
|
+
type Message = { jsonrpc?: unknown; id?: unknown; method?: unknown; params?: unknown };
|
|
38
|
+
|
|
39
|
+
const failure = (id: unknown, code: number, message: string) => ({ jsonrpc: "2.0", id: id ?? null, error: { code, message } });
|
|
40
|
+
const success = (id: unknown, result: unknown) => ({ jsonrpc: "2.0", id, result });
|
|
41
|
+
const text = (value: string, isError = false) => ({ content: [{ type: "text", text: value }], ...(isError ? { isError } : {}) });
|
|
42
|
+
|
|
43
|
+
function errorText(error: unknown): string {
|
|
44
|
+
if (error instanceof z.ZodError) return error.issues.map((issue) => `${issue.path.join(".") || "input"}: ${issue.message}`).join("; ");
|
|
45
|
+
return error instanceof Error ? error.message : String(error);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The answer to one JSON-RPC message, or null for a notification. */
|
|
49
|
+
export async function answer<C>(options: McpOptions<C>, message: Message, caller: C): Promise<object | null> {
|
|
50
|
+
if (typeof message !== "object" || message === null || message.jsonrpc !== "2.0" || typeof message.method !== "string") {
|
|
51
|
+
return failure(message?.id, -32600, "Invalid request");
|
|
52
|
+
}
|
|
53
|
+
if (message.id === undefined || message.id === null) return null;
|
|
54
|
+
const params = (typeof message.params === "object" && message.params !== null ? message.params : {}) as Record<string, unknown>;
|
|
55
|
+
const closed = options.closed?.(caller) ?? null;
|
|
56
|
+
const tools = closed ? [] : options.tools.filter((tool) => tool.allowed?.(caller) ?? true);
|
|
57
|
+
switch (message.method) {
|
|
58
|
+
case "initialize": {
|
|
59
|
+
const asked = typeof params.protocolVersion === "string" ? params.protocolVersion : "";
|
|
60
|
+
return success(message.id, {
|
|
61
|
+
protocolVersion: VERSIONS.includes(asked) ? asked : VERSIONS[0],
|
|
62
|
+
capabilities: { tools: { listChanged: false } },
|
|
63
|
+
serverInfo: { name: options.name, version: options.version },
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
case "ping":
|
|
67
|
+
return success(message.id, {});
|
|
68
|
+
case "tools/list":
|
|
69
|
+
return success(message.id, {
|
|
70
|
+
tools: tools.map((tool) => ({
|
|
71
|
+
name: tool.name,
|
|
72
|
+
description: tool.description,
|
|
73
|
+
inputSchema: z.toJSONSchema(tool.input, { io: "input" }),
|
|
74
|
+
})),
|
|
75
|
+
});
|
|
76
|
+
case "tools/call": {
|
|
77
|
+
if (closed) return success(message.id, text(closed, true));
|
|
78
|
+
const tool = tools.find((entry) => entry.name === params.name);
|
|
79
|
+
if (!tool) return failure(message.id, -32602, `Unknown tool: ${String(params.name)}`);
|
|
80
|
+
try {
|
|
81
|
+
return success(message.id, text(await tool.run(tool.input.parse(params.arguments ?? {}), caller)));
|
|
82
|
+
} catch (error) {
|
|
83
|
+
return success(message.id, text(errorText(error), true));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
default:
|
|
87
|
+
return failure(message.id, -32601, `Unknown method: ${message.method}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function readBody(request: IncomingMessage): Promise<string | null> {
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
const chunks: Buffer[] = [];
|
|
94
|
+
let size = 0;
|
|
95
|
+
request.on("data", (chunk: Buffer) => {
|
|
96
|
+
size += chunk.length;
|
|
97
|
+
if (size > MAX_BODY) {
|
|
98
|
+
resolve(null);
|
|
99
|
+
request.destroy();
|
|
100
|
+
} else chunks.push(chunk);
|
|
101
|
+
});
|
|
102
|
+
request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
103
|
+
request.on("error", reject);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function send(response: ServerResponse, status: number, body?: unknown) {
|
|
108
|
+
response.writeHead(status, body === undefined ? {} : { "content-type": "application/json" });
|
|
109
|
+
response.end(body === undefined ? undefined : JSON.stringify(body));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Browsers send an Origin header and agents do not, so a web page can never reach the tools.
|
|
113
|
+
export function createMcpServer<C>(options: McpOptions<C>): Server {
|
|
114
|
+
return createServer(async (request, response) => {
|
|
115
|
+
try {
|
|
116
|
+
if (new URL(request.url ?? "/", "http://localhost").pathname !== "/mcp") return send(response, 404);
|
|
117
|
+
if (request.headers.origin) return send(response, 403);
|
|
118
|
+
if (request.method !== "POST") {
|
|
119
|
+
response.setHeader("allow", "POST");
|
|
120
|
+
return send(response, 405);
|
|
121
|
+
}
|
|
122
|
+
const token = /^Bearer (\S+)$/.exec(request.headers.authorization ?? "")?.[1];
|
|
123
|
+
const caller = token ? await options.authorize(token) : null;
|
|
124
|
+
if (caller === null) return send(response, 401, failure(null, -32001, "Unknown token"));
|
|
125
|
+
const raw = await readBody(request);
|
|
126
|
+
if (raw === null) return send(response, 413);
|
|
127
|
+
let parsed: unknown;
|
|
128
|
+
try {
|
|
129
|
+
parsed = JSON.parse(raw);
|
|
130
|
+
} catch {
|
|
131
|
+
return send(response, 400, failure(null, -32700, "Parse error"));
|
|
132
|
+
}
|
|
133
|
+
const messages = (Array.isArray(parsed) ? parsed : [parsed]) as Message[];
|
|
134
|
+
const answers = (await Promise.all(messages.map((message) => answer(options, message, caller)))).filter(Boolean);
|
|
135
|
+
if (answers.length === 0) return send(response, 202);
|
|
136
|
+
send(response, 200, Array.isArray(parsed) ? answers : answers[0]);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
console.error("Linear MCP request failed", error);
|
|
139
|
+
if (!response.headersSent) send(response, 500);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Listens on 127.0.0.1, on the preferred port when it is free. Returns the port. */
|
|
145
|
+
export function listenLocal(server: Server, preferred: number | null): Promise<number> {
|
|
146
|
+
const attempt = (port: number) =>
|
|
147
|
+
new Promise<number>((resolve, reject) => {
|
|
148
|
+
const fail = (error: Error) => reject(error);
|
|
149
|
+
server.once("error", fail);
|
|
150
|
+
server.listen(port, "127.0.0.1", () => {
|
|
151
|
+
server.off("error", fail);
|
|
152
|
+
resolve((server.address() as AddressInfo).port);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
return preferred ? attempt(preferred).catch(() => attempt(0)) : attempt(0);
|
|
156
|
+
}
|
package/server/stores.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { type ZodType, z } from "zod";
|
|
4
|
+
import { type IssueMap, IssueMapSchema, type LaunchInput, LaunchInputSchema } from "../shared/live";
|
|
5
|
+
|
|
6
|
+
const MAX_MAPS = 200;
|
|
7
|
+
/** A launch still waiting after this long is dropped, so a stale one never starts by surprise. */
|
|
8
|
+
export const PENDING_LAUNCH_MAX_AGE_MS = 60 * 60_000;
|
|
9
|
+
|
|
10
|
+
export interface JsonFile<T> {
|
|
11
|
+
read(): Promise<T>;
|
|
12
|
+
/** Changes run one at a time, so a read, change, and write never interleave. */
|
|
13
|
+
update<R>(change: (current: T) => { next: T; result: R }): Promise<R>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createJsonFile<T>(file: string, schema: ZodType<T>, empty: () => T): JsonFile<T> {
|
|
17
|
+
let value: T | null = null;
|
|
18
|
+
let queue: Promise<unknown> = Promise.resolve();
|
|
19
|
+
async function load(): Promise<T> {
|
|
20
|
+
if (value !== null) return value;
|
|
21
|
+
try {
|
|
22
|
+
value = schema.parse(JSON.parse(await readFile(file, "utf8")));
|
|
23
|
+
} catch {
|
|
24
|
+
value = empty();
|
|
25
|
+
}
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
async function save(next: T) {
|
|
29
|
+
value = next;
|
|
30
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
31
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
32
|
+
await writeFile(temporary, JSON.stringify(next), { mode: 0o600 });
|
|
33
|
+
await rename(temporary, file);
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
read: load,
|
|
37
|
+
update(change) {
|
|
38
|
+
const run = queue
|
|
39
|
+
.catch(() => undefined)
|
|
40
|
+
.then(async () => {
|
|
41
|
+
const { next, result } = change(await load());
|
|
42
|
+
await save(next);
|
|
43
|
+
return result;
|
|
44
|
+
});
|
|
45
|
+
queue = run;
|
|
46
|
+
return run;
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface MapStore {
|
|
52
|
+
get(identifier: string): Promise<IssueMap | null>;
|
|
53
|
+
set(map: IssueMap): Promise<void>;
|
|
54
|
+
delete(identifier: string): Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Explore maps survive daemon restarts, so a finished explore run is never paid for twice. */
|
|
58
|
+
export function createMapStore(file: string): MapStore {
|
|
59
|
+
const store = createJsonFile<Record<string, IssueMap>>(
|
|
60
|
+
file,
|
|
61
|
+
z.record(z.string(), IssueMapSchema),
|
|
62
|
+
() => ({}),
|
|
63
|
+
);
|
|
64
|
+
return {
|
|
65
|
+
async get(identifier) {
|
|
66
|
+
return (await store.read())[identifier] ?? null;
|
|
67
|
+
},
|
|
68
|
+
set(map) {
|
|
69
|
+
return store.update((current) => {
|
|
70
|
+
const kept = Object.values({ ...current, [map.identifier]: map })
|
|
71
|
+
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
|
72
|
+
.slice(0, MAX_MAPS);
|
|
73
|
+
return { next: Object.fromEntries(kept.map((entry) => [entry.identifier, entry])), result: undefined };
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
delete(identifier) {
|
|
77
|
+
return store.update((current) => {
|
|
78
|
+
const { [identifier]: _removed, ...rest } = current;
|
|
79
|
+
return { next: rest, result: undefined };
|
|
80
|
+
});
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const PendingLaunchSchema = LaunchInputSchema.extend({ queuedAt: z.number() });
|
|
86
|
+
type PendingLaunch = z.infer<typeof PendingLaunchSchema>;
|
|
87
|
+
|
|
88
|
+
export interface LaunchQueue {
|
|
89
|
+
add(launch: LaunchInput): Promise<void>;
|
|
90
|
+
/** Removes and returns the issue's waiting launches, so each one starts at most once. */
|
|
91
|
+
claim(identifier: string): Promise<LaunchInput[]>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Agents that wait for an explore run are saved to disk, so a plugin reload or daemon
|
|
95
|
+
// restart while the run works still starts them once the map is ready.
|
|
96
|
+
export function createLaunchQueue(file: string, now = () => Date.now()): LaunchQueue {
|
|
97
|
+
const store = createJsonFile<Record<string, PendingLaunch[]>>(
|
|
98
|
+
file,
|
|
99
|
+
z.record(z.string(), z.array(PendingLaunchSchema)),
|
|
100
|
+
() => ({}),
|
|
101
|
+
);
|
|
102
|
+
return {
|
|
103
|
+
add(launch) {
|
|
104
|
+
return store.update((current) => ({
|
|
105
|
+
next: {
|
|
106
|
+
...current,
|
|
107
|
+
[launch.identifier]: [...(current[launch.identifier] ?? []), { ...launch, queuedAt: now() }],
|
|
108
|
+
},
|
|
109
|
+
result: undefined,
|
|
110
|
+
}));
|
|
111
|
+
},
|
|
112
|
+
claim(identifier) {
|
|
113
|
+
return store.update((current) => {
|
|
114
|
+
const { [identifier]: waiting = [], ...others } = current;
|
|
115
|
+
const fresh = waiting.filter((launch) => now() - launch.queuedAt < PENDING_LAUNCH_MAX_AGE_MS);
|
|
116
|
+
if (fresh.length < waiting.length) {
|
|
117
|
+
console.error(`Dropped ${waiting.length - fresh.length} stale Linear launch for ${identifier}`);
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
next: others,
|
|
121
|
+
result: fresh.map(({ queuedAt: _queuedAt, ...launch }: PendingLaunch) => launch),
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|