@yannelli/paseo-linear-plugin 1.0.1 → 1.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/OVERVIEW.md +20 -2
- package/README.md +91 -5
- package/client/access.tsx +66 -0
- package/client/browser.tsx +12 -9
- package/client/graph-camera.tsx +219 -0
- package/client/graph-labels.ts +147 -0
- package/client/launch-choices.ts +15 -4
- package/client/launch-guidance.tsx +86 -0
- package/client/launch-plan.ts +86 -16
- package/client/launch.tsx +19 -5
- package/client/live-agents.tsx +522 -0
- package/client/live-cursor.tsx +293 -0
- package/client/live-feed.tsx +151 -0
- package/client/live-graph.tsx +518 -0
- package/client/live-header.tsx +341 -0
- package/client/live-issues.tsx +345 -0
- package/client/live-map.tsx +345 -0
- package/client/live-panel.tsx +465 -0
- package/client/live-timeline.ts +376 -0
- package/client/pill.tsx +393 -0
- package/client/project-init.tsx +168 -0
- package/client/settings-access.tsx +56 -0
- package/client/settings-live.tsx +145 -0
- package/client/settings-projects.tsx +9 -0
- package/client/tool-buttons.tsx +68 -0
- package/client/web.ts +45 -0
- package/index.client.tsx +39 -1
- package/index.server.ts +29 -5
- package/package.json +1 -1
- package/server/agent-hooks.ts +37 -0
- package/server/agent-runs.ts +312 -0
- package/server/agent-tools.ts +240 -0
- package/server/handlers.ts +12 -1
- package/server/init.ts +65 -0
- package/server/links.ts +153 -0
- package/server/live.ts +360 -0
- package/server/mcp-http.ts +152 -0
- package/server/stores.ts +126 -0
- package/server/subagent-logs.ts +146 -0
- package/server/sync.ts +127 -0
- package/shared/activity.ts +312 -0
- package/shared/agent-hooks.ts +68 -0
- package/shared/agent-tools.ts +130 -0
- package/shared/graph-camera.ts +93 -0
- package/shared/graph-geometry.ts +122 -0
- package/shared/graph-model.ts +319 -0
- package/shared/links.ts +252 -0
- package/shared/live.ts +243 -0
- package/shared/map-model.ts +192 -0
- package/shared/project-setup.ts +55 -0
- package/shared/prompts.ts +65 -2
- package/shared/settings.ts +88 -1
- package/shared/shell-activity.ts +309 -0
- package/shared/shell-lexer.ts +180 -0
- package/shared/subagents.ts +106 -0
- package/shared/todo-sync.ts +158 -0
package/shared/links.ts
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// Links between repository files, for the Linear Live graph: code imports in common languages,
|
|
2
|
+
// links in Markdown, HTML, and CSS, entry points in package files, and repo paths named in
|
|
3
|
+
// config and scripts. Pure: the daemon reads the files and checks which candidates exist.
|
|
4
|
+
|
|
5
|
+
export type LinkKind = "import" | "link" | "mention";
|
|
6
|
+
|
|
7
|
+
export interface FileLink {
|
|
8
|
+
from: string;
|
|
9
|
+
to: string;
|
|
10
|
+
kind: LinkKind;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** A reference found in a file, with the repo paths it may point to, best first. */
|
|
14
|
+
export interface Reference {
|
|
15
|
+
kind: LinkKind;
|
|
16
|
+
candidates: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface LinkContext {
|
|
20
|
+
/** Path aliases from tsconfig or jsconfig, such as "@/" to "src/". */
|
|
21
|
+
aliases: readonly { prefix: string; targets: readonly string[] }[];
|
|
22
|
+
/** Workspace packages by name, with their folder and entry file. */
|
|
23
|
+
packages: ReadonlyMap<string, { dir: string; entry: string | null }>;
|
|
24
|
+
/** The module path from go.mod. */
|
|
25
|
+
goModule: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const EMPTY_CONTEXT: LinkContext = { aliases: [], packages: new Map(), goModule: null };
|
|
29
|
+
|
|
30
|
+
const SCRIPT_EXTENSIONS = [".ts", ".tsx", ".d.ts", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".json", ".vue", ".svelte"];
|
|
31
|
+
/** A Go import names a package folder; this suffix asks the daemon for the folder's Go files. */
|
|
32
|
+
export const GO_PACKAGE = "/*.go";
|
|
33
|
+
|
|
34
|
+
function extension(file: string): string {
|
|
35
|
+
const name = file.split("/").pop() ?? file;
|
|
36
|
+
const dot = name.lastIndexOf(".");
|
|
37
|
+
return dot > 0 ? name.slice(dot).toLowerCase() : "";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function dirname(file: string): string {
|
|
41
|
+
const index = file.lastIndexOf("/");
|
|
42
|
+
return index < 0 ? "" : file.slice(0, index);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Joins and normalizes a repo path; null when it leaves the repository. */
|
|
46
|
+
export function joinPath(base: string, target: string): string | null {
|
|
47
|
+
const parts: string[] = [];
|
|
48
|
+
for (const part of `${base}/${target}`.split("/")) {
|
|
49
|
+
if (part === "" || part === ".") continue;
|
|
50
|
+
if (part === "..") {
|
|
51
|
+
if (parts.length === 0) return null;
|
|
52
|
+
parts.pop();
|
|
53
|
+
} else parts.push(part);
|
|
54
|
+
}
|
|
55
|
+
return parts.length ? parts.join("/") : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function all(pattern: RegExp, text: string, group = 1): string[] {
|
|
59
|
+
return [...text.matchAll(pattern)].map((match) => match[group] ?? "").filter(Boolean);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// A script specifier may leave out the extension, name a folder with an index file, or name
|
|
63
|
+
// the .js file that a .ts file compiles to.
|
|
64
|
+
function scriptCandidates(base: string): string[] {
|
|
65
|
+
const list = [base, ...SCRIPT_EXTENSIONS.map((ext) => base + ext), ...SCRIPT_EXTENSIONS.map((ext) => `${base}/index${ext}`)];
|
|
66
|
+
const compiled = /\.(m|c)?jsx?$/.exec(base);
|
|
67
|
+
if (compiled) {
|
|
68
|
+
const stem = base.slice(0, -compiled[0].length);
|
|
69
|
+
list.unshift(`${stem}.ts`, `${stem}.tsx`, `${stem}.${compiled[1] ?? ""}ts`);
|
|
70
|
+
}
|
|
71
|
+
return list;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function scriptSpecifier(from: string, spec: string, context: LinkContext): string[] {
|
|
75
|
+
if (spec.startsWith(".")) {
|
|
76
|
+
const base = joinPath(dirname(from), spec);
|
|
77
|
+
return base ? scriptCandidates(base) : [];
|
|
78
|
+
}
|
|
79
|
+
for (const alias of context.aliases) {
|
|
80
|
+
if (!spec.startsWith(alias.prefix)) continue;
|
|
81
|
+
return alias.targets.flatMap((target) => {
|
|
82
|
+
const base = joinPath(target, spec.slice(alias.prefix.length));
|
|
83
|
+
return base ? scriptCandidates(base) : [];
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
for (const [name, pkg] of context.packages) {
|
|
87
|
+
if (spec === name && pkg.entry) return scriptCandidates(pkg.entry);
|
|
88
|
+
if (spec.startsWith(`${name}/`)) {
|
|
89
|
+
const base = joinPath(pkg.dir, spec.slice(name.length + 1));
|
|
90
|
+
return base ? [...scriptCandidates(base), ...scriptCandidates(joinPath(pkg.dir, `src/${spec.slice(name.length + 1)}`) ?? base)] : [];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function scriptImports(text: string): string[] {
|
|
97
|
+
return [
|
|
98
|
+
...all(/\b(?:import|export)\s+(?:type\s+)?[^'"`;]*?\bfrom\s*["']([^"']+)["']/g, text),
|
|
99
|
+
...all(/\bimport\s*["']([^"']+)["']/g, text),
|
|
100
|
+
...all(/\b(?:require|import)\s*\(\s*["']([^"']+)["']\s*\)/g, text),
|
|
101
|
+
];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function pythonImports(from: string, text: string): string[][] {
|
|
105
|
+
const results: string[][] = [];
|
|
106
|
+
const module = (base: string, name: string) => {
|
|
107
|
+
const stem = joinPath(base, name.replace(/\./g, "/"));
|
|
108
|
+
return stem ? [`${stem}.py`, `${stem}.pyi`, `${stem}/__init__.py`] : [];
|
|
109
|
+
};
|
|
110
|
+
for (const match of text.matchAll(/^\s*from\s+(\.*)([\w.]*)\s+import\s+([\w*, ()]+)/gm)) {
|
|
111
|
+
const [, dots = "", name = "", names = ""] = match;
|
|
112
|
+
if (dots) {
|
|
113
|
+
let base = dirname(from);
|
|
114
|
+
for (let level = 1; level < dots.length; level += 1) base = dirname(base);
|
|
115
|
+
if (name) results.push(module(base, name));
|
|
116
|
+
else for (const item of names.replace(/[()]/g, "").split(",")) results.push(module(base, item.trim()));
|
|
117
|
+
} else results.push([...module("", name), ...module("src", name)]);
|
|
118
|
+
}
|
|
119
|
+
for (const match of text.matchAll(/^\s*import\s+([\w.]+(?:\s*,\s*[\w.]+)*)/gm)) {
|
|
120
|
+
for (const name of (match[1] ?? "").split(",")) results.push([...module("", name.trim()), ...module("src", name.trim())]);
|
|
121
|
+
}
|
|
122
|
+
return results;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function goImports(text: string, context: LinkContext): string[][] {
|
|
126
|
+
if (!context.goModule) return [];
|
|
127
|
+
const prefix = `${context.goModule}/`;
|
|
128
|
+
const specs = [...all(/^\s*import\s+(?:\w+\s+)?"([^"]+)"/gm, text)];
|
|
129
|
+
for (const block of all(/^\s*import\s*\(([\s\S]*?)\)/gm, text)) specs.push(...all(/"([^"]+)"/g, block));
|
|
130
|
+
return specs.filter((spec) => spec.startsWith(prefix)).map((spec) => [`${spec.slice(prefix.length)}${GO_PACKAGE}`]);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function rustImports(from: string, text: string): string[][] {
|
|
134
|
+
const name = from.split("/").pop() ?? "";
|
|
135
|
+
const own = /^(mod|lib|main)\.rs$/.test(name) ? dirname(from) : joinPath(dirname(from), name.replace(/\.rs$/, ""));
|
|
136
|
+
const results = all(/^\s*(?:pub\s+)?mod\s+(\w+)\s*;/gm, text).map((mod) => [`${own ?? ""}/${mod}.rs`, `${own ?? ""}/${mod}/mod.rs`].map((file) => file.replace(/^\//, "")));
|
|
137
|
+
for (const spec of all(/^\s*(?:pub\s+)?use\s+crate::([\w:]+)/gm, text)) {
|
|
138
|
+
const parts = spec.split("::");
|
|
139
|
+
for (let size = parts.length; size > 0; size -= 1) {
|
|
140
|
+
const stem = `src/${parts.slice(0, size).join("/")}`;
|
|
141
|
+
results.push([`${stem}.rs`, `${stem}/mod.rs`]);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return results;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Repo paths named in config, scripts, and prose: a word with a slash and an extension, or a
|
|
148
|
+
// relative path. Each is tried from the file's folder and from the repository root.
|
|
149
|
+
function mentions(from: string, text: string): string[][] {
|
|
150
|
+
const words = all(/(?:^|[\s"'`(=:,[])((?:\.{1,2}\/)?[\w@.-]+(?:\/[\w@.-]+)+\.[A-Za-z][\w]{0,7})(?=$|[\s"'`),:;\]#?])/gm, text);
|
|
151
|
+
return [...new Set(words)].map((word) => {
|
|
152
|
+
const local = joinPath(dirname(from), word);
|
|
153
|
+
const rooted = word.startsWith(".") ? null : joinPath("", word);
|
|
154
|
+
return [local, rooted].filter((candidate): candidate is string => candidate !== null);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function markdownLinks(from: string, text: string): string[][] {
|
|
159
|
+
return all(/\]\(\s*<?([^)\s>]+)>?(?:\s+"[^"]*")?\s*\)/g, text)
|
|
160
|
+
.map((target) => target.split("#")[0] ?? "")
|
|
161
|
+
.filter((target) => target && !/^[a-z][\w+.-]*:/i.test(target))
|
|
162
|
+
.map((target) => {
|
|
163
|
+
const resolved = target.startsWith("/") ? joinPath("", target) : joinPath(dirname(from), decodeURI(target));
|
|
164
|
+
return resolved ? [resolved, `${resolved}/README.md`] : [];
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function packageEntries(from: string, text: string): string[][] {
|
|
169
|
+
let pkg: Record<string, unknown>;
|
|
170
|
+
try {
|
|
171
|
+
pkg = JSON.parse(text) as Record<string, unknown>;
|
|
172
|
+
} catch {
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
const strings: string[] = [];
|
|
176
|
+
const collect = (value: unknown) => {
|
|
177
|
+
if (typeof value === "string") strings.push(value);
|
|
178
|
+
else if (value && typeof value === "object") for (const inner of Object.values(value)) collect(inner);
|
|
179
|
+
};
|
|
180
|
+
for (const key of ["main", "module", "types", "typings", "bin", "exports", "browser"]) collect(pkg[key]);
|
|
181
|
+
const dir = dirname(from);
|
|
182
|
+
const entries = strings.filter((value) => /^\.?\.?\/?[\w@.-]/.test(value) && !value.includes("*"));
|
|
183
|
+
const scripts = mentions(from, Object.values((pkg.scripts as Record<string, string>) ?? {}).join("\n"));
|
|
184
|
+
return [...entries.map((value) => [joinPath(dir, value)].filter((entry): entry is string => entry !== null)), ...scripts];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The references in one file, with candidate paths for each. */
|
|
188
|
+
export function fileReferences(from: string, text: string, context: LinkContext = EMPTY_CONTEXT): Reference[] {
|
|
189
|
+
const ext = extension(from);
|
|
190
|
+
const name = from.split("/").pop() ?? "";
|
|
191
|
+
const refs: Reference[] = [];
|
|
192
|
+
const add = (kind: LinkKind, lists: string[][]) => {
|
|
193
|
+
for (const candidates of lists) if (candidates.length) refs.push({ kind, candidates });
|
|
194
|
+
};
|
|
195
|
+
if ([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue", ".svelte", ".astro"].includes(ext)) {
|
|
196
|
+
add("import", scriptImports(text).map((spec) => scriptSpecifier(from, spec, context)));
|
|
197
|
+
} else if (ext === ".py" || ext === ".pyi") add("import", pythonImports(from, text));
|
|
198
|
+
else if (ext === ".go") add("import", goImports(text, context));
|
|
199
|
+
else if (ext === ".rs") add("import", rustImports(from, text));
|
|
200
|
+
else if (ext === ".rb") {
|
|
201
|
+
add("import", all(/\brequire_relative\s*\(?\s*["']([^"']+)["']/g, text).map((spec) => {
|
|
202
|
+
const base = joinPath(dirname(from), spec);
|
|
203
|
+
return base ? [base.endsWith(".rb") ? base : `${base}.rb`] : [];
|
|
204
|
+
}));
|
|
205
|
+
} else if ([".css", ".scss", ".sass", ".less"].includes(ext)) {
|
|
206
|
+
add("link", all(/@(?:import|use|forward)\s+(?:url\()?["']([^"']+)["']/g, text).map((spec) => {
|
|
207
|
+
const base = joinPath(dirname(from), spec);
|
|
208
|
+
if (!base) return [];
|
|
209
|
+
const partial = `${dirname(base)}/_${base.split("/").pop()}`.replace(/^\//, "");
|
|
210
|
+
return [base, `${base}.css`, `${base}.scss`, `${partial}.scss`, `${base}.less`];
|
|
211
|
+
}));
|
|
212
|
+
} else if (ext === ".md" || ext === ".mdx") add("link", markdownLinks(from, text));
|
|
213
|
+
else if (ext === ".html" || ext === ".htm") {
|
|
214
|
+
add("link", all(/\b(?:src|href)\s*=\s*["']([^"'#?]+)["']/g, text)
|
|
215
|
+
.filter((target) => !/^[a-z][\w+.-]*:|^\/\//i.test(target))
|
|
216
|
+
.map((target) => [joinPath(target.startsWith("/") ? "" : dirname(from), target)].filter((value): value is string => value !== null)));
|
|
217
|
+
} else if (name === "package.json") add("link", packageEntries(from, text));
|
|
218
|
+
else if (/^[tj]sconfig(\..+)?\.json$/.test(name)) {
|
|
219
|
+
add("link", all(/"extends"\s*:\s*"(\.[^"]+)"/g, text).map((spec) => {
|
|
220
|
+
const base = joinPath(dirname(from), spec);
|
|
221
|
+
return base ? [base, `${base}.json`] : [];
|
|
222
|
+
}));
|
|
223
|
+
}
|
|
224
|
+
// Every text file can name repo paths, as a workflow names the script it runs.
|
|
225
|
+
add("mention", mentions(from, text));
|
|
226
|
+
return refs;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Reads tsconfig paths, such as {"@/*": ["./src/*"]}, as prefix aliases. */
|
|
230
|
+
export function aliasesFrom(config: unknown, configDir = ""): LinkContext["aliases"] {
|
|
231
|
+
const options = (config as { compilerOptions?: { baseUrl?: string; paths?: Record<string, string[]> } })?.compilerOptions;
|
|
232
|
+
const base = joinPath(configDir, options?.baseUrl ?? ".") ?? "";
|
|
233
|
+
return Object.entries(options?.paths ?? {}).flatMap(([pattern, targets]) => {
|
|
234
|
+
if (!pattern.endsWith("*") || !Array.isArray(targets)) return [];
|
|
235
|
+
const resolved = targets
|
|
236
|
+
.filter((target) => typeof target === "string" && target.endsWith("*"))
|
|
237
|
+
.map((target) => joinPath(base, target.slice(0, -1)) ?? "");
|
|
238
|
+
return [{ prefix: pattern.slice(0, -1), targets: resolved }];
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Strips comments and trailing commas, which tsconfig files allow. */
|
|
243
|
+
export function parseLooseJson(text: string): unknown {
|
|
244
|
+
const clean = text
|
|
245
|
+
.replace(/("(?:[^"\\]|\\.)*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (match, string: string | undefined) => string ?? "")
|
|
246
|
+
.replace(/,(\s*[}\]])/g, "$1");
|
|
247
|
+
try {
|
|
248
|
+
return JSON.parse(clean);
|
|
249
|
+
} catch {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
}
|
package/shared/live.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { defineRpc } from "@getpaseo/plugin";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { McpHttpServerSchema, ToolPolicySchema } from "./agent-tools";
|
|
4
|
+
import { IssueCardSchema, WorkflowStateSchema } from "./linear";
|
|
5
|
+
|
|
6
|
+
// Contracts for Linear Live, todo sync, and project setup. Agents the plugin starts for itself
|
|
7
|
+
// carry their own labels so they get no pill, no sync, and no place in the linked agent list.
|
|
8
|
+
|
|
9
|
+
export const MapFileSchema = z.object({
|
|
10
|
+
path: z.string().min(1),
|
|
11
|
+
/** Issue key the file belongs to: the parent or one of its sub-issues. */
|
|
12
|
+
issue: z.string().min(1),
|
|
13
|
+
});
|
|
14
|
+
export type MapFile = z.infer<typeof MapFileSchema>;
|
|
15
|
+
|
|
16
|
+
export const IssueMapSchema = z.object({
|
|
17
|
+
identifier: z.string(),
|
|
18
|
+
source: z.enum(["semantic", "explore"]),
|
|
19
|
+
files: z.array(MapFileSchema),
|
|
20
|
+
createdAt: z.string(),
|
|
21
|
+
});
|
|
22
|
+
export type IssueMap = z.infer<typeof IssueMapSchema>;
|
|
23
|
+
|
|
24
|
+
export const JobSchema = z.object({
|
|
25
|
+
id: z.string(),
|
|
26
|
+
status: z.enum(["running", "done", "failed"]),
|
|
27
|
+
agentId: z.string().nullable(),
|
|
28
|
+
error: z.string().nullable(),
|
|
29
|
+
startedAt: z.string(),
|
|
30
|
+
});
|
|
31
|
+
export type Job = z.infer<typeof JobSchema>;
|
|
32
|
+
|
|
33
|
+
/** What the explore agent must return. */
|
|
34
|
+
export const ExploreResultSchema = z.object({
|
|
35
|
+
files: z.array(z.object({ path: z.string().min(1), issue: z.string().min(1) })).max(200),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export const InitProposalSchema = z.object({
|
|
39
|
+
instructions: z.string().default(""),
|
|
40
|
+
steps: z.array(z.string()).default([]),
|
|
41
|
+
implement: z.string().default(""),
|
|
42
|
+
review: z.string().default(""),
|
|
43
|
+
});
|
|
44
|
+
export type InitProposal = z.infer<typeof InitProposalSchema>;
|
|
45
|
+
|
|
46
|
+
/** Files an explore run may list; the map and the graph show this many and more. */
|
|
47
|
+
export const MAX_EXPLORE_FILES = 150;
|
|
48
|
+
export const MAX_LIST_DIRS = 40;
|
|
49
|
+
export const MAX_DIR_ENTRIES = 60;
|
|
50
|
+
|
|
51
|
+
export const listFilesRpc = defineRpc({
|
|
52
|
+
name: "linear.live.files",
|
|
53
|
+
input: z.object({ agentId: z.string().min(1), dirs: z.array(z.string()).max(MAX_LIST_DIRS) }),
|
|
54
|
+
output: z.object({
|
|
55
|
+
dirs: z.array(z.object({ dir: z.string(), files: z.array(z.string()), truncated: z.boolean() })),
|
|
56
|
+
}),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
export const MAX_LINK_FILES = 200;
|
|
60
|
+
|
|
61
|
+
/** Imports and other links between the files on the graph. */
|
|
62
|
+
export const linksRpc = defineRpc({
|
|
63
|
+
name: "linear.live.links",
|
|
64
|
+
input: z.object({ agentId: z.string().min(1), files: z.array(z.string().min(1)).max(MAX_LINK_FILES) }),
|
|
65
|
+
output: z.object({
|
|
66
|
+
links: z.array(z.object({ from: z.string(), to: z.string(), kind: z.enum(["import", "link", "mention"]) })),
|
|
67
|
+
}),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const SubagentLogSchema = z.object({
|
|
71
|
+
id: z.string(),
|
|
72
|
+
type: z.string(),
|
|
73
|
+
description: z.string(),
|
|
74
|
+
/** The parent's call that started it, when Claude recorded one. */
|
|
75
|
+
toolUseId: z.string().nullable(),
|
|
76
|
+
updatedAt: z.number(),
|
|
77
|
+
/** Its last reply ended the run. */
|
|
78
|
+
finished: z.boolean(),
|
|
79
|
+
items: z.array(
|
|
80
|
+
z.object({
|
|
81
|
+
type: z.literal("tool_call"),
|
|
82
|
+
callId: z.string(),
|
|
83
|
+
name: z.string(),
|
|
84
|
+
status: z.enum(["running", "completed", "failed"]),
|
|
85
|
+
detail: z.record(z.string(), z.unknown()),
|
|
86
|
+
}),
|
|
87
|
+
),
|
|
88
|
+
});
|
|
89
|
+
export type SubagentLog = z.infer<typeof SubagentLogSchema>;
|
|
90
|
+
|
|
91
|
+
/** Tool calls of the subagents an agent started inside its provider. */
|
|
92
|
+
export const subagentLogsRpc = defineRpc({
|
|
93
|
+
name: "linear.live.subagents",
|
|
94
|
+
input: z.object({ agentId: z.string().min(1) }),
|
|
95
|
+
output: z.object({ runs: z.array(SubagentLogSchema) }),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/** Forgets the saved explore map of the agent's issue, so the map falls back to ticket text. */
|
|
99
|
+
export const clearMapRpc = defineRpc({
|
|
100
|
+
name: "linear.live.map.clear",
|
|
101
|
+
input: z.object({ agentId: z.string().min(1) }),
|
|
102
|
+
output: z.object({ cleared: z.boolean() }),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
export const liveMapRpc = defineRpc({
|
|
106
|
+
name: "linear.live.map",
|
|
107
|
+
input: z.object({ identifier: z.string().min(1) }),
|
|
108
|
+
output: z.object({ map: IssueMapSchema.nullable(), job: JobSchema.nullable() }),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
export const exploreRpc = defineRpc({
|
|
112
|
+
name: "linear.live.explore",
|
|
113
|
+
input: z.object({ agentId: z.string().min(1), force: z.boolean().default(false) }),
|
|
114
|
+
output: z.object({ job: JobSchema }),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
export const LaunchConfigSchema = z.object({
|
|
118
|
+
provider: z.string().min(1),
|
|
119
|
+
thinkingOptionId: z.string().optional(),
|
|
120
|
+
modeId: z.string().optional(),
|
|
121
|
+
/** Only the folder of the issue's Claude Code hooks; see agent-hooks.ts. */
|
|
122
|
+
providerOptions: z.object({ extraArgs: z.object({ "plugin-dir": z.string().min(1) }) }).optional(),
|
|
123
|
+
/** Only the plugin's own MCP server; see agent-tools.ts. */
|
|
124
|
+
mcpServers: z.object({ linear: McpHttpServerSchema }).optional(),
|
|
125
|
+
toolPolicy: ToolPolicySchema.optional(),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// The daemon maps the files first and then starts the agent, so closing the app mid-way
|
|
129
|
+
// still starts it. With a saved map, or when exploring cannot start, the agent starts now.
|
|
130
|
+
export const LaunchInputSchema = z.object({
|
|
131
|
+
workspaceId: z.string().min(1),
|
|
132
|
+
identifier: z.string().min(1),
|
|
133
|
+
keyScope: z.string().nullable(),
|
|
134
|
+
agent: z.object({
|
|
135
|
+
config: LaunchConfigSchema,
|
|
136
|
+
title: z.string(),
|
|
137
|
+
prompt: z.string(),
|
|
138
|
+
labels: z.record(z.string(), z.string()),
|
|
139
|
+
}),
|
|
140
|
+
card: IssueCardSchema,
|
|
141
|
+
});
|
|
142
|
+
export type LaunchInput = z.infer<typeof LaunchInputSchema>;
|
|
143
|
+
|
|
144
|
+
export const launchAfterExploreRpc = defineRpc({
|
|
145
|
+
name: "linear.live.launch",
|
|
146
|
+
input: LaunchInputSchema,
|
|
147
|
+
output: z.object({ agentId: z.string().nullable(), job: JobSchema }),
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
export const SyncMoveSchema = z.object({
|
|
151
|
+
identifier: z.string(),
|
|
152
|
+
from: WorkflowStateSchema,
|
|
153
|
+
to: WorkflowStateSchema,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
export const syncNowRpc = defineRpc({
|
|
157
|
+
name: "linear.sync.now",
|
|
158
|
+
input: z.object({ agentId: z.string().min(1) }),
|
|
159
|
+
output: z.object({ moves: z.array(SyncMoveSchema), skipped: z.string().nullable() }),
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
export const initStartRpc = defineRpc({
|
|
163
|
+
name: "linear.init.start",
|
|
164
|
+
input: z.object({ projectId: z.string().min(1), rootPath: z.string().min(1) }),
|
|
165
|
+
output: z.object({ job: JobSchema }),
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
export const initStatusRpc = defineRpc({
|
|
169
|
+
name: "linear.init.status",
|
|
170
|
+
input: z.object({ jobId: z.string().min(1) }),
|
|
171
|
+
output: z.object({ job: JobSchema.nullable(), proposal: InitProposalSchema.nullable() }),
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const EXTENSIONS =
|
|
175
|
+
"ts|tsx|js|jsx|mjs|cjs|py|go|rs|rb|java|kt|swift|c|cc|cpp|h|hpp|cs|php|vue|svelte|css|scss|html|md|mdx|json|ya?ml|toml|sql|sh|prisma|graphql";
|
|
176
|
+
const PATH = new RegExp(
|
|
177
|
+
`(?:^|[\\s\`'"(\\[])((?:[\\w@.-]+/)*[\\w@.-]+\\.(?:${EXTENSIONS}))(?=$|[\\s\`'")\\],.:;!?])`,
|
|
178
|
+
"g",
|
|
179
|
+
);
|
|
180
|
+
const DIRECTORY = /`((?:[\w@.-]+\/)+)`/g;
|
|
181
|
+
const ISSUE_KEY = /\b[A-Z][A-Z0-9]*-\d+\b/g;
|
|
182
|
+
|
|
183
|
+
export function pathsIn(text: string): string[] {
|
|
184
|
+
const found = new Set<string>();
|
|
185
|
+
for (const match of text.matchAll(PATH)) if (match[1]) found.add(match[1].replace(/^\.\//, ""));
|
|
186
|
+
for (const match of text.matchAll(DIRECTORY)) if (match[1]) found.add(match[1]);
|
|
187
|
+
return [...found].filter((path) => !/^https?:/.test(path) && !path.includes("//"));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export interface SemanticIssue {
|
|
191
|
+
identifier: string;
|
|
192
|
+
title: string;
|
|
193
|
+
description: string | null;
|
|
194
|
+
children: readonly { identifier: string; title: string }[];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Linear sends sub-issue titles but not their descriptions, so a path in the parent's text
|
|
198
|
+
// belongs to the sub-issue its line names and otherwise to the parent.
|
|
199
|
+
export function semanticMap(issue: SemanticIssue, now = new Date()): IssueMap {
|
|
200
|
+
const owners = new Map<string, string>();
|
|
201
|
+
const childKeys = new Set(issue.children.map((child) => child.identifier.toUpperCase()));
|
|
202
|
+
for (const child of issue.children) {
|
|
203
|
+
for (const path of pathsIn(child.title)) owners.set(path, child.identifier);
|
|
204
|
+
}
|
|
205
|
+
const lines = `${issue.title}\n${issue.description ?? ""}`.split("\n");
|
|
206
|
+
for (const line of lines) {
|
|
207
|
+
const named = (line.match(ISSUE_KEY) ?? []).find((key) => childKeys.has(key));
|
|
208
|
+
for (const path of pathsIn(line)) {
|
|
209
|
+
if (!owners.has(path)) owners.set(path, named ?? issue.identifier);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
identifier: issue.identifier,
|
|
214
|
+
source: "semantic",
|
|
215
|
+
files: [...owners].map(([path, owner]) => ({ path, issue: owner })),
|
|
216
|
+
createdAt: now.toISOString(),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Parses the last JSON object in an agent reply: a fenced block, the whole text, or braces. */
|
|
221
|
+
export function lastJsonObject(reply: string): unknown {
|
|
222
|
+
const fences = [...reply.matchAll(/```(?:json)?\s*\n([\s\S]*?)```/g)].reverse();
|
|
223
|
+
const candidates = [...fences.map((match) => match[1] ?? ""), reply.trim()];
|
|
224
|
+
const start = reply.indexOf("{");
|
|
225
|
+
const end = reply.lastIndexOf("}");
|
|
226
|
+
if (start >= 0 && end > start) candidates.push(reply.slice(start, end + 1));
|
|
227
|
+
for (const candidate of candidates) {
|
|
228
|
+
try {
|
|
229
|
+
const value: unknown = JSON.parse(candidate);
|
|
230
|
+
if (value && typeof value === "object" && !Array.isArray(value)) return value;
|
|
231
|
+
} catch {
|
|
232
|
+
// Not JSON; try the next candidate.
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Directory of a repo path, with a trailing slash, or "" for the root. */
|
|
239
|
+
export function dirOf(path: string): string {
|
|
240
|
+
if (path.endsWith("/")) return path;
|
|
241
|
+
const index = path.lastIndexOf("/");
|
|
242
|
+
return index < 0 ? "" : path.slice(0, index + 1);
|
|
243
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import type { DirTouch, FileTouch } from "./activity";
|
|
2
|
+
import { dirOf, type MapFile } from "./live";
|
|
3
|
+
|
|
4
|
+
// The Linear Live map: predicted files and touched files grouped into folder zones, and the
|
|
5
|
+
// column layout for those zones. Pure, so it runs in tests without React Native.
|
|
6
|
+
|
|
7
|
+
const MAX_GHOSTS = 4;
|
|
8
|
+
/** Listing chips are hidden once a zone has this many real tiles. */
|
|
9
|
+
const GHOSTS_UNTIL = 6;
|
|
10
|
+
|
|
11
|
+
export interface MapTile {
|
|
12
|
+
path: string;
|
|
13
|
+
name: string;
|
|
14
|
+
owner: string | null;
|
|
15
|
+
ghost: boolean;
|
|
16
|
+
onMap: boolean;
|
|
17
|
+
touch: "none" | "read" | "edited" | "created";
|
|
18
|
+
added: number;
|
|
19
|
+
removed: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type ZoneExplored = "none" | "listed" | "searched";
|
|
23
|
+
|
|
24
|
+
export interface MapZone {
|
|
25
|
+
dir: string;
|
|
26
|
+
tiles: MapTile[];
|
|
27
|
+
/** Files in the zone that are on the map. */
|
|
28
|
+
predicted: number;
|
|
29
|
+
/** Files in the zone the agent read or changed. */
|
|
30
|
+
touched: number;
|
|
31
|
+
/** Touched files that are on the map. */
|
|
32
|
+
hits: number;
|
|
33
|
+
explored: ZoneExplored;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface MapModel {
|
|
37
|
+
zones: MapZone[];
|
|
38
|
+
touched: number;
|
|
39
|
+
hits: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Search hits alone do not count: one broad search would flood the map with off-map tiles.
|
|
43
|
+
function isTouched(file: FileTouch): boolean {
|
|
44
|
+
return file.reads + file.edits > 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function touchState(file: FileTouch | undefined): MapTile["touch"] {
|
|
48
|
+
if (!file) return "none";
|
|
49
|
+
if (file.created) return "created";
|
|
50
|
+
return file.edits > 0 ? "edited" : "read";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface Predictions {
|
|
54
|
+
files: ReadonlyMap<string, string>;
|
|
55
|
+
dirs: ReadonlyMap<string, string>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// A map entry ending in "/" names a directory: it seeds a zone and owns the files inside.
|
|
59
|
+
function predictions(files: readonly MapFile[]): Predictions {
|
|
60
|
+
const paths = new Map<string, string>();
|
|
61
|
+
const dirs = new Map<string, string>();
|
|
62
|
+
for (const file of files) {
|
|
63
|
+
const target = file.path.endsWith("/") ? dirs : paths;
|
|
64
|
+
if (!target.has(file.path)) target.set(file.path, file.issue.toUpperCase());
|
|
65
|
+
}
|
|
66
|
+
return { files: paths, dirs };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function ownerOf(path: string, predicted: Predictions): string | null {
|
|
70
|
+
const direct = predicted.files.get(path);
|
|
71
|
+
if (direct) return direct;
|
|
72
|
+
for (const [dir, owner] of predicted.dirs) if (path.startsWith(dir)) return owner;
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Zone directories: predicted ones in map order, then touched ones by name. */
|
|
77
|
+
export function zoneDirs(files: readonly MapFile[], touches: readonly FileTouch[]): string[] {
|
|
78
|
+
const dirs = new Set(files.map((file) => dirOf(file.path)));
|
|
79
|
+
const extra = touches.filter(isTouched).map((file) => dirOf(file.path));
|
|
80
|
+
for (const dir of [...new Set(extra)].sort()) dirs.add(dir);
|
|
81
|
+
return [...dirs];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// A listed folder counts for itself; a recursive search counts for every folder under it,
|
|
85
|
+
// except a search of the whole repository, which would mark every zone.
|
|
86
|
+
function exploredState(dir: string, dirs: readonly DirTouch[]): ZoneExplored {
|
|
87
|
+
let state: ZoneExplored = "none";
|
|
88
|
+
for (const touch of dirs) {
|
|
89
|
+
const covers = touch.path === dir || (touch.deep && touch.path !== "" && dir.startsWith(touch.path));
|
|
90
|
+
if (covers && touch.deep) return "searched";
|
|
91
|
+
if (covers) state = "listed";
|
|
92
|
+
}
|
|
93
|
+
return state;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function buildMapModel(
|
|
97
|
+
files: readonly MapFile[],
|
|
98
|
+
touches: readonly FileTouch[],
|
|
99
|
+
listing: ReadonlyMap<string, readonly string[]>,
|
|
100
|
+
dirs: readonly DirTouch[] = [],
|
|
101
|
+
): MapModel {
|
|
102
|
+
const predicted = predictions(files);
|
|
103
|
+
const touched = new Map(touches.filter(isTouched).map((file) => [file.path, file]));
|
|
104
|
+
const zones = zoneDirs(files, touches).map((dir): MapZone => {
|
|
105
|
+
const inZone = (path: string) => dirOf(path) === dir;
|
|
106
|
+
const real = new Set([...predicted.files.keys(), ...touched.keys()].filter(inZone));
|
|
107
|
+
const tiles = [...real].sort().map((path): MapTile => {
|
|
108
|
+
const touch = touched.get(path);
|
|
109
|
+
const owner = ownerOf(path, predicted);
|
|
110
|
+
return {
|
|
111
|
+
path,
|
|
112
|
+
name: path.slice(dir.length),
|
|
113
|
+
owner,
|
|
114
|
+
ghost: false,
|
|
115
|
+
onMap: owner !== null,
|
|
116
|
+
touch: touchState(touch),
|
|
117
|
+
added: touch?.added ?? 0,
|
|
118
|
+
removed: touch?.removed ?? 0,
|
|
119
|
+
};
|
|
120
|
+
});
|
|
121
|
+
const ghosts = (tiles.length >= GHOSTS_UNTIL ? [] : (listing.get(dir) ?? []))
|
|
122
|
+
.filter((name) => !real.has(`${dir}${name}`))
|
|
123
|
+
.slice(0, MAX_GHOSTS)
|
|
124
|
+
.map((name): MapTile => {
|
|
125
|
+
const path = `${dir}${name}`;
|
|
126
|
+
const owner = ownerOf(path, predicted);
|
|
127
|
+
const onMap = owner !== null;
|
|
128
|
+
return { path, name, owner, ghost: true, onMap, touch: "none", added: 0, removed: 0 };
|
|
129
|
+
});
|
|
130
|
+
return {
|
|
131
|
+
dir,
|
|
132
|
+
tiles: [...tiles, ...ghosts],
|
|
133
|
+
predicted: tiles.filter((tile) => tile.onMap).length,
|
|
134
|
+
touched: tiles.filter((tile) => tile.touch !== "none").length,
|
|
135
|
+
hits: tiles.filter((tile) => tile.touch !== "none" && tile.onMap).length,
|
|
136
|
+
explored: exploredState(dir, dirs),
|
|
137
|
+
};
|
|
138
|
+
});
|
|
139
|
+
let hits = 0;
|
|
140
|
+
for (const path of touched.keys()) if (ownerOf(path, predicted)) hits += 1;
|
|
141
|
+
return { zones, touched: touched.size, hits };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function hitRateLine(model: MapModel): string {
|
|
145
|
+
if (model.touched === 0) return "No files touched yet";
|
|
146
|
+
const noun = model.touched === 1 ? "file" : "files";
|
|
147
|
+
return `${model.touched} ${noun} touched, ${model.hits} on the map`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export const ZONE_GAP = 10;
|
|
151
|
+
|
|
152
|
+
// Rough zone height from its tiles, so zones can go to the shortest column. Tiles wrap, so
|
|
153
|
+
// count how many fit per row by name length.
|
|
154
|
+
function zoneHeight(zone: MapZone, width: number): number {
|
|
155
|
+
const inner = Math.max(120, width - 26);
|
|
156
|
+
let rows = 1;
|
|
157
|
+
let used = 0;
|
|
158
|
+
for (const tile of zone.tiles) {
|
|
159
|
+
const tileWidth = Math.min(inner, 32 + tile.name.length * 7);
|
|
160
|
+
if (used > 0 && used + tileWidth > inner) {
|
|
161
|
+
rows += 1;
|
|
162
|
+
used = 0;
|
|
163
|
+
}
|
|
164
|
+
used += tileWidth + 6;
|
|
165
|
+
}
|
|
166
|
+
return 46 + rows * 32;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Splits zones into columns in map order. A zone in `placed` keeps its column; a new zone
|
|
171
|
+
* goes to the shortest column and is added to `placed`.
|
|
172
|
+
*/
|
|
173
|
+
export function masonry(
|
|
174
|
+
zones: readonly MapZone[],
|
|
175
|
+
columns: number,
|
|
176
|
+
width: number,
|
|
177
|
+
placed: Map<string, number>,
|
|
178
|
+
): MapZone[][] {
|
|
179
|
+
const result: MapZone[][] = Array.from({ length: columns }, () => []);
|
|
180
|
+
const heights: number[] = Array.from({ length: columns }, () => 0);
|
|
181
|
+
const columnWidth = (width - ZONE_GAP * (columns - 1)) / columns;
|
|
182
|
+
for (const zone of zones) {
|
|
183
|
+
let column = placed.get(zone.dir);
|
|
184
|
+
if (column === undefined || column >= columns) {
|
|
185
|
+
column = heights.indexOf(Math.min(...heights));
|
|
186
|
+
placed.set(zone.dir, column);
|
|
187
|
+
}
|
|
188
|
+
result[column]?.push(zone);
|
|
189
|
+
heights[column] = (heights[column] ?? 0) + zoneHeight(zone, columnWidth) + ZONE_GAP;
|
|
190
|
+
}
|
|
191
|
+
return result;
|
|
192
|
+
}
|