@yannelli/paseo-linear-plugin 1.0.0 → 1.1.0-beta.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.
Files changed (47) hide show
  1. package/OVERVIEW.md +19 -1
  2. package/README.md +77 -6
  3. package/client/access.tsx +66 -0
  4. package/client/agent-options.ts +12 -2
  5. package/client/agent-preferences.ts +112 -0
  6. package/client/browser.tsx +12 -9
  7. package/client/graph-labels.ts +137 -0
  8. package/client/launch-choices.ts +18 -5
  9. package/client/launch-plan.ts +88 -39
  10. package/client/launch.tsx +9 -4
  11. package/client/live-agents.tsx +407 -0
  12. package/client/live-cursor.tsx +291 -0
  13. package/client/live-feed.tsx +151 -0
  14. package/client/live-graph.tsx +434 -0
  15. package/client/live-header.tsx +312 -0
  16. package/client/live-issues.tsx +345 -0
  17. package/client/live-map.tsx +345 -0
  18. package/client/live-panel.tsx +444 -0
  19. package/client/live-timeline.ts +341 -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 +121 -0
  24. package/client/settings-projects.tsx +9 -0
  25. package/index.client.tsx +39 -1
  26. package/index.server.ts +24 -4
  27. package/package.json +1 -1
  28. package/server/agent-runs.ts +299 -0
  29. package/server/handlers.ts +12 -1
  30. package/server/init.ts +64 -0
  31. package/server/links.ts +153 -0
  32. package/server/live.ts +334 -0
  33. package/server/stores.ts +119 -0
  34. package/server/sync.ts +127 -0
  35. package/shared/activity.ts +308 -0
  36. package/shared/graph-geometry.ts +122 -0
  37. package/shared/graph-model.ts +319 -0
  38. package/shared/links.ts +252 -0
  39. package/shared/live.ts +200 -0
  40. package/shared/map-model.ts +192 -0
  41. package/shared/project-setup.ts +55 -0
  42. package/shared/prompts.ts +16 -1
  43. package/shared/settings.ts +57 -1
  44. package/shared/shell-activity.ts +309 -0
  45. package/shared/shell-lexer.ts +180 -0
  46. package/shared/subagents.ts +106 -0
  47. 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,334 @@
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
+ linksRpc,
15
+ listFilesRpc,
16
+ liveMapRpc,
17
+ MAX_DIR_ENTRIES,
18
+ } from "../shared/live";
19
+ import { AGENT_LABELS, issueSnapshot } from "../shared/prompts";
20
+ import { type LinearSettings, projectEnabled } from "../shared/settings";
21
+ import {
22
+ createJobStore,
23
+ finishInternalRun,
24
+ type Paseo,
25
+ READ_ONLY_RULES,
26
+ startInternalRun,
27
+ } from "./agent-runs";
28
+ import type { LinearAccess } from "./handlers";
29
+ import { fileLinks } from "./links";
30
+ import type { LaunchQueue, MapStore } from "./stores";
31
+
32
+ const EXPLORE_TIMEOUT_MS = 20 * 60_000;
33
+ const RECOVER_INTERVAL_MS = 30_000;
34
+ const EXPLORE_TITLE = /^Explore ([A-Za-z][A-Za-z0-9]*-\d+)$/;
35
+ const SKIPPED_ENTRIES = new Set([".git", "node_modules", ".DS_Store"]);
36
+
37
+ export interface AgentInfo {
38
+ id: string;
39
+ cwd: string;
40
+ workspaceId: string | null;
41
+ /** Paseo project of the agent's workspace, when the workspace is known. */
42
+ projectId: string | null;
43
+ provider: string;
44
+ labels: Record<string, string>;
45
+ }
46
+
47
+ export async function agentInfo(paseo: Paseo, agentId: string): Promise<AgentInfo> {
48
+ const handle = paseo.agents.ref(agentId);
49
+ await handle.refresh();
50
+ const agent = handle.current();
51
+ if (!agent) throw new Error("The agent was not found");
52
+ const workspaceId = agent.workspaceId ?? null;
53
+ const workspace = workspaceId
54
+ ? await paseo.workspaces
55
+ .ref(workspaceId)
56
+ .refresh()
57
+ .catch(() => null)
58
+ : null;
59
+ return {
60
+ id: agent.id,
61
+ cwd: agent.cwd,
62
+ workspaceId,
63
+ projectId: workspace?.projectId ?? null,
64
+ provider: agent.provider,
65
+ labels: agent.labels ?? {},
66
+ };
67
+ }
68
+
69
+ // The client names directories, never a root: the root is the agent's own folder, and a
70
+ // directory that resolves outside it, through ".." or a symlink, is skipped.
71
+ export async function listDirectories(cwd: string, dirs: readonly string[]) {
72
+ const root = await realpath(cwd);
73
+ const results: { dir: string; files: string[]; truncated: boolean }[] = [];
74
+ for (const dir of new Set(dirs)) {
75
+ const relative = dir === "" ? "" : relativePath(dir, root);
76
+ if (relative === null) continue;
77
+ const resolved = await realpath(path.resolve(root, relative)).catch(() => null);
78
+ if (!resolved || (resolved !== root && !resolved.startsWith(`${root}${path.sep}`))) continue;
79
+ const entries = await readdir(resolved, { withFileTypes: true }).catch(() => []);
80
+ const files = entries
81
+ .filter((entry) => entry.isFile() && !SKIPPED_ENTRIES.has(entry.name))
82
+ .map((entry) => entry.name)
83
+ .sort((a, b) => a.localeCompare(b));
84
+ results.push({
85
+ dir,
86
+ files: files.slice(0, MAX_DIR_ENTRIES),
87
+ truncated: files.length > MAX_DIR_ENTRIES,
88
+ });
89
+ }
90
+ return results;
91
+ }
92
+
93
+ export function explorePrompt(issue: IssueDetail): string {
94
+ const keys = [issue.identifier, ...issue.children.map((child) => child.identifier)];
95
+ return [
96
+ `Map this repository for Linear issue ${issue.identifier}: ${issue.title}`,
97
+ "",
98
+ ...READ_ONLY_RULES,
99
+ "",
100
+ issueSnapshot(issue, { includeComments: false }),
101
+ "",
102
+ "List the files an engineer will most likely read or change for this issue and each sub-issue.",
103
+ "Include files that will probably be created, at their expected path. List at most 40 files.",
104
+ `Set "issue" to the sub-issue key the file belongs to, or ${issue.identifier} for the whole issue.`,
105
+ `Valid keys: ${keys.join(", ")}.`,
106
+ "Reply with only a JSON object in a ```json block, with repo-relative paths:",
107
+ `{"files":[{"path":"src/example.ts","issue":"${keys[1] ?? keys[0]}"}]}`,
108
+ ].join("\n");
109
+ }
110
+
111
+ export function toIssueMap(
112
+ issue: IssueDetail,
113
+ cwd: string,
114
+ result: z.infer<typeof ExploreResultSchema>,
115
+ now = new Date(),
116
+ ): IssueMap {
117
+ const keys = new Set([issue.identifier, ...issue.children.map((child) => child.identifier)]);
118
+ const seen = new Set<string>();
119
+ const files: IssueMap["files"] = [];
120
+ for (const entry of result.files) {
121
+ const filePath = relativePath(entry.path, cwd);
122
+ if (!filePath || seen.has(filePath)) continue;
123
+ seen.add(filePath);
124
+ const key = entry.issue.trim().toUpperCase();
125
+ files.push({ path: filePath, issue: keys.has(key) ? key : issue.identifier });
126
+ }
127
+ return { identifier: issue.identifier, source: "explore", files, createdAt: now.toISOString() };
128
+ }
129
+
130
+ export interface LiveDependencies {
131
+ access: LinearAccess;
132
+ maps: MapStore;
133
+ launches: LaunchQueue;
134
+ readSettings(): Promise<LinearSettings | null>;
135
+ }
136
+
137
+ type WorkspaceHandle = Awaited<ReturnType<Paseo["workspaces"]["open"]>>;
138
+
139
+ /** Starts the implementing agent and attaches the issue card to its timeline. */
140
+ export async function startLaunch(workspace: WorkspaceHandle, launch: LaunchInput) {
141
+ const agent = await workspace.agents.create(launch.agent);
142
+ await agent.timeline
143
+ .append({
144
+ type: "plugin",
145
+ id: `linear-issue-${launch.card.identifier}`,
146
+ kind: ISSUE_CARD_KIND,
147
+ version: 1,
148
+ data: launch.card,
149
+ })
150
+ .catch((error: unknown) => console.error("Linear issue card was not attached", error));
151
+ return agent.id;
152
+ }
153
+
154
+ export function registerLive(server: PluginServerContext, dependencies: LiveDependencies) {
155
+ const { access, maps, launches, readSettings } = dependencies;
156
+ const jobs = createJobStore<IssueMap>();
157
+ const recovering = new Map<string, Promise<Job | null>>();
158
+ const recoveredAt = new Map<string, number>();
159
+
160
+ // Called whenever an explore run ends, by any path. Claiming first means a launch that two
161
+ // paths see still starts one agent.
162
+ async function startWaiting(paseo: Paseo, identifier: string) {
163
+ for (const launch of await launches.claim(identifier)) {
164
+ await startLaunch(paseo.workspaces.ref(launch.workspaceId), launch).catch((error: unknown) =>
165
+ console.error(`Linear agent for ${identifier} did not start after exploring`, error),
166
+ );
167
+ }
168
+ }
169
+
170
+ function track(paseo: Paseo, job: Job) {
171
+ void jobs.wait(job.id).then(() => startWaiting(paseo, job.id));
172
+ return job;
173
+ }
174
+
175
+ function saveMap(issue: IssueDetail, cwd: string) {
176
+ return async (value: z.infer<typeof ExploreResultSchema>) => {
177
+ const map = toIssueMap(issue, cwd, value);
178
+ await maps.set(map);
179
+ return map;
180
+ };
181
+ }
182
+
183
+ // A plugin reload or daemon restart drops the job but not the explore agent. Finds that
184
+ // agent by its label and finishes the run, or starts waiting launches when there is none.
185
+ async function adopt(paseo: Paseo, identifier: string): Promise<Job | null> {
186
+ const { entries } = await paseo.agents.list({
187
+ filter: { labels: { [AGENT_LABELS.explore]: identifier } },
188
+ page: { limit: 10 },
189
+ });
190
+ const orphan = entries.map((entry) => entry.agent).find((agent) => !agent.archivedAt);
191
+ if (!orphan) {
192
+ await startWaiting(paseo, identifier);
193
+ return null;
194
+ }
195
+ const job = await jobs.start(identifier, async () => {
196
+ const { linear } = await access.connect(orphan.labels?.[AGENT_LABELS.project] ?? null);
197
+ const issue = await linear.getIssue(identifier);
198
+ const handle = paseo.agents.ref(orphan.id);
199
+ const result = finishInternalRun(handle, orphan.cwd, ExploreResultSchema, EXPLORE_TIMEOUT_MS);
200
+ return { agentId: orphan.id, result: result.then(saveMap(issue, orphan.cwd)) };
201
+ });
202
+ return track(paseo, job);
203
+ }
204
+
205
+ function recover(paseo: Paseo, identifier: string): Promise<Job | null> {
206
+ const existing = jobs.get(identifier)?.job;
207
+ if (existing?.status === "running") return Promise.resolve(existing);
208
+ const inFlight = recovering.get(identifier);
209
+ if (inFlight) return inFlight;
210
+ recoveredAt.set(identifier, Date.now());
211
+ const run = adopt(paseo, identifier).finally(() => recovering.delete(identifier));
212
+ recovering.set(identifier, run);
213
+ return run;
214
+ }
215
+
216
+ server.handle(listFilesRpc, async ({ agentId, dirs }, { paseo }) => {
217
+ const agent = await agentInfo(paseo, agentId);
218
+ return { dirs: await listDirectories(agent.cwd, dirs) };
219
+ });
220
+
221
+ server.handle(linksRpc, async ({ agentId, files }, { paseo }) => {
222
+ const agent = await agentInfo(paseo, agentId);
223
+ return { links: await fileLinks(agent.cwd, files) };
224
+ });
225
+
226
+ server.handle(liveMapRpc, async ({ identifier }, { paseo }) => {
227
+ const map = await maps.get(identifier);
228
+ let job = jobs.get(identifier)?.job ?? null;
229
+ const checked = recoveredAt.get(identifier) ?? 0;
230
+ if (!map && !job && Date.now() - checked > RECOVER_INTERVAL_MS) {
231
+ job = await recover(paseo, identifier).catch(() => null);
232
+ }
233
+ return { map: map ?? (await maps.get(identifier)), job };
234
+ });
235
+
236
+ // Finishes runs whose job a reload dropped, as soon as the agent's turn ends.
237
+ server.on("agent.turn_ended", async (event, { paseo }) => {
238
+ const identifier = EXPLORE_TITLE.exec(event.agent.title ?? "")?.[1];
239
+ if (!identifier || jobs.get(identifier)?.job.status === "running") return;
240
+ await recover(paseo, identifier).catch((error: unknown) =>
241
+ console.error(`Linear explore run for ${identifier} was not recovered`, error),
242
+ );
243
+ });
244
+
245
+ interface ExploreTarget {
246
+ identifier: string;
247
+ keyScope: string | null;
248
+ cwd: string;
249
+ /** Opened only when a new run starts. */
250
+ workspace: () => Promise<WorkspaceHandle>;
251
+ provider: string;
252
+ force: boolean;
253
+ }
254
+
255
+ // One explore run per issue: a running job is shared, and a saved map is reused.
256
+ async function ensureExplore(paseo: Paseo, target: ExploreTarget): Promise<Job> {
257
+ const { identifier } = target;
258
+ const existing = jobs.get(identifier)?.job;
259
+ if (existing?.status === "running" || (existing?.status === "done" && !target.force)) {
260
+ return existing;
261
+ }
262
+ const stored = target.force ? null : await maps.get(identifier);
263
+ if (stored) {
264
+ return { id: identifier, status: "done", agentId: null, error: null, startedAt: stored.createdAt };
265
+ }
266
+ const adopted = target.force ? null : await recover(paseo, identifier).catch(() => null);
267
+ if (adopted?.status === "running") return adopted;
268
+ const job = await jobs.start(identifier, async () => {
269
+ const { linear } = await access.connect(target.keyScope);
270
+ const issue = await linear.getIssue(identifier);
271
+ const labels: Record<string, string> = { [AGENT_LABELS.explore]: identifier };
272
+ if (target.keyScope) labels[AGENT_LABELS.project] = target.keyScope;
273
+ const run = await startInternalRun({
274
+ paseo,
275
+ workspace: await target.workspace(),
276
+ cwd: target.cwd,
277
+ provider: target.provider,
278
+ title: `Explore ${identifier}`,
279
+ prompt: explorePrompt(issue),
280
+ labels,
281
+ schema: ExploreResultSchema,
282
+ timeoutMs: EXPLORE_TIMEOUT_MS,
283
+ });
284
+ return { agentId: run.agentId, result: run.result.then(saveMap(issue, target.cwd)) };
285
+ });
286
+ return track(paseo, job);
287
+ }
288
+ async function requireEnabled(projectId: string | null) {
289
+ const settings = await readSettings();
290
+ if (settings && !projectEnabled(settings.access, projectId)) {
291
+ throw new Error("Linear is off for this project");
292
+ }
293
+ return settings;
294
+ }
295
+
296
+ server.handle(exploreRpc, async ({ agentId, force }, { paseo }) => {
297
+ const agent = await agentInfo(paseo, agentId);
298
+ const identifier = agent.labels[AGENT_LABELS.issue];
299
+ if (!identifier) throw new Error("This agent is not linked to a Linear issue");
300
+ const settings = await requireEnabled(agent.projectId);
301
+ const { workspaceId, cwd } = agent;
302
+ const job = await ensureExplore(paseo, {
303
+ identifier,
304
+ keyScope: agent.labels[AGENT_LABELS.project] ?? null,
305
+ cwd,
306
+ workspace: async () =>
307
+ workspaceId ? paseo.workspaces.ref(workspaceId) : paseo.workspaces.open(cwd),
308
+ provider: settings?.live.exploreProvider || agent.provider,
309
+ force,
310
+ });
311
+ return { job };
312
+ });
313
+
314
+ server.handle(launchAfterExploreRpc, async (input, { paseo }) => {
315
+ const workspace = paseo.workspaces.ref(input.workspaceId);
316
+ const info = await workspace.refresh();
317
+ if (!info) throw new Error("The workspace was not found");
318
+ const settings = await requireEnabled(info.projectId);
319
+ const job = await ensureExplore(paseo, {
320
+ identifier: input.identifier,
321
+ keyScope: input.keyScope,
322
+ cwd: info.workspaceDirectory ?? info.projectRootPath,
323
+ workspace: async () => workspace,
324
+ provider: settings?.live.exploreProvider || input.agent.config.provider,
325
+ force: false,
326
+ });
327
+ // A failed or timed-out explore run still starts the agent; the map falls back to ticket text.
328
+ if (job.status !== "running") return { agentId: await startLaunch(workspace, input), job };
329
+ await launches.add(input);
330
+ // The run may have ended while the launch was saved; claiming makes a second start a no-op.
331
+ if (jobs.get(job.id)?.job.status !== "running") await startWaiting(paseo, input.identifier);
332
+ return { agentId: null, job };
333
+ });
334
+ }
@@ -0,0 +1,119 @@
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
+ 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
+ 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
+ }
55
+
56
+ /** Explore maps survive daemon restarts, so a finished explore run is never paid for twice. */
57
+ export function createMapStore(file: string): MapStore {
58
+ const store = createJsonFile<Record<string, IssueMap>>(
59
+ file,
60
+ z.record(z.string(), IssueMapSchema),
61
+ () => ({}),
62
+ );
63
+ return {
64
+ async get(identifier) {
65
+ return (await store.read())[identifier] ?? null;
66
+ },
67
+ set(map) {
68
+ return store.update((current) => {
69
+ const kept = Object.values({ ...current, [map.identifier]: map })
70
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
71
+ .slice(0, MAX_MAPS);
72
+ return { next: Object.fromEntries(kept.map((entry) => [entry.identifier, entry])), result: undefined };
73
+ });
74
+ },
75
+ };
76
+ }
77
+
78
+ const PendingLaunchSchema = LaunchInputSchema.extend({ queuedAt: z.number() });
79
+ type PendingLaunch = z.infer<typeof PendingLaunchSchema>;
80
+
81
+ export interface LaunchQueue {
82
+ add(launch: LaunchInput): Promise<void>;
83
+ /** Removes and returns the issue's waiting launches, so each one starts at most once. */
84
+ claim(identifier: string): Promise<LaunchInput[]>;
85
+ }
86
+
87
+ // Agents that wait for an explore run are saved to disk, so a plugin reload or daemon
88
+ // restart while the run works still starts them once the map is ready.
89
+ export function createLaunchQueue(file: string, now = () => Date.now()): LaunchQueue {
90
+ const store = createJsonFile<Record<string, PendingLaunch[]>>(
91
+ file,
92
+ z.record(z.string(), z.array(PendingLaunchSchema)),
93
+ () => ({}),
94
+ );
95
+ return {
96
+ add(launch) {
97
+ return store.update((current) => ({
98
+ next: {
99
+ ...current,
100
+ [launch.identifier]: [...(current[launch.identifier] ?? []), { ...launch, queuedAt: now() }],
101
+ },
102
+ result: undefined,
103
+ }));
104
+ },
105
+ claim(identifier) {
106
+ return store.update((current) => {
107
+ const { [identifier]: waiting = [], ...others } = current;
108
+ const fresh = waiting.filter((launch) => now() - launch.queuedAt < PENDING_LAUNCH_MAX_AGE_MS);
109
+ if (fresh.length < waiting.length) {
110
+ console.error(`Dropped ${waiting.length - fresh.length} stale Linear launch for ${identifier}`);
111
+ }
112
+ return {
113
+ next: others,
114
+ result: fresh.map(({ queuedAt: _queuedAt, ...launch }: PendingLaunch) => launch),
115
+ };
116
+ });
117
+ },
118
+ };
119
+ }