@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.
Files changed (65) hide show
  1. package/OVERVIEW.md +32 -2
  2. package/README.md +138 -11
  3. package/client/access.tsx +67 -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/compat.ts +23 -5
  8. package/client/connect.tsx +3 -2
  9. package/client/launch-assignments.tsx +182 -0
  10. package/client/launch-choices.ts +23 -6
  11. package/client/launch-guidance.tsx +103 -0
  12. package/client/launch-plan.ts +166 -42
  13. package/client/launch.tsx +55 -9
  14. package/client/live-agents.tsx +446 -0
  15. package/client/live-feed.tsx +151 -0
  16. package/client/live-header.tsx +310 -0
  17. package/client/live-issues.tsx +345 -0
  18. package/client/live-map.tsx +351 -0
  19. package/client/live-panel.tsx +392 -0
  20. package/client/live-timeline.ts +379 -0
  21. package/client/pill.tsx +394 -0
  22. package/client/plugin-icon.tsx +75 -0
  23. package/client/project-init.tsx +192 -0
  24. package/client/project-knowledge.tsx +87 -0
  25. package/client/settings-access.tsx +56 -0
  26. package/client/settings-agents.tsx +113 -0
  27. package/client/settings-fields.tsx +11 -3
  28. package/client/settings-icon.tsx +277 -0
  29. package/client/settings-live.tsx +128 -0
  30. package/client/settings-overrides.tsx +148 -0
  31. package/client/settings-projects.tsx +26 -4
  32. package/client/settings-prompts.tsx +3 -3
  33. package/client/timeline-card.tsx +2 -1
  34. package/client/tool-buttons.tsx +68 -0
  35. package/client/ui.tsx +16 -7
  36. package/client/web.ts +81 -0
  37. package/index.client.tsx +160 -51
  38. package/index.server.ts +32 -5
  39. package/package.json +1 -1
  40. package/server/agent-hooks.ts +37 -0
  41. package/server/agent-runs.ts +312 -0
  42. package/server/agent-tools.ts +276 -0
  43. package/server/handlers.ts +12 -1
  44. package/server/init.ts +89 -0
  45. package/server/inspect.ts +392 -0
  46. package/server/knowledge.ts +157 -0
  47. package/server/live.ts +371 -0
  48. package/server/mcp-http.ts +156 -0
  49. package/server/stores.ts +126 -0
  50. package/server/subagent-logs.ts +146 -0
  51. package/server/sync.ts +133 -0
  52. package/shared/activity.ts +268 -0
  53. package/shared/agent-hooks.ts +68 -0
  54. package/shared/agent-tools.ts +157 -0
  55. package/shared/custom-icon.ts +188 -0
  56. package/shared/knowledge.ts +255 -0
  57. package/shared/live.ts +248 -0
  58. package/shared/map-model.ts +240 -0
  59. package/shared/project-setup.ts +56 -0
  60. package/shared/prompts.ts +109 -2
  61. package/shared/settings.ts +145 -3
  62. package/shared/shell-activity.ts +309 -0
  63. package/shared/shell-lexer.ts +180 -0
  64. package/shared/subagents.ts +106 -0
  65. package/shared/todo-sync.ts +158 -0
@@ -0,0 +1,276 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import path from "node:path";
3
+ import type { PluginServerContext } from "@getpaseo/plugin/server";
4
+ import { z } from "zod";
5
+ import {
6
+ AGENT_TOOLS,
7
+ type AgentConfig,
8
+ agentToolsRpc,
9
+ assignmentFor,
10
+ childBrief,
11
+ EditIssueInput,
12
+ editText,
13
+ issueText,
14
+ LINEAR_TOOLS,
15
+ ReadIssueInput,
16
+ StartAgentInput,
17
+ TOOLS_SERVER,
18
+ taskCount,
19
+ type ToolScope,
20
+ ToolScopeSchema,
21
+ toolPolicy,
22
+ WaitAgentInput,
23
+ } from "../shared/agent-tools";
24
+ import { AGENT_LABELS } from "../shared/prompts";
25
+ import { type LinearSettings, linearSettings, projectDefaults, type ToolSettings } from "../shared/settings";
26
+ import { lastReply, type Paseo } from "./agent-runs";
27
+ import type { LinearAccess } from "./handlers";
28
+ import { createMcpServer, listenLocal, type McpTool, tool } from "./mcp-http";
29
+ import type { LinearService } from "./queries";
30
+ import { createJsonFile } from "./stores";
31
+
32
+ // Each launch gets a grant: a random token for the agent's MCP config, and the issue tree it
33
+ // reaches. The file keeps the port and a hash of each token, never the token, so agents keep
34
+ // their tools after a restart.
35
+
36
+ const MAX_GRANTS = 500;
37
+
38
+ const GrantSchema = z.object({
39
+ id: z.string(),
40
+ hash: z.string(),
41
+ scope: ToolScopeSchema,
42
+ /** Only agents started from an issue may start agents, so the tree is one level deep. */
43
+ canStart: z.boolean(),
44
+ createdAt: z.string(),
45
+ });
46
+ export type Grant = z.infer<typeof GrantSchema>;
47
+
48
+ const FileSchema = z.object({ port: z.number().int().nullable(), grants: z.array(GrantSchema) });
49
+
50
+ const hashOf = (token: string) => createHash("sha256").update(token).digest("hex");
51
+
52
+ /** A grant with the tool settings of its project, read when the request comes in. */
53
+ export type Caller = Grant & { tools: ToolSettings };
54
+
55
+ export const TOOLS_OFF = "The Linear tools are off for this project in the Linear plugin settings in Paseo.";
56
+ const RUNNING = new Set(["initializing", "running"]);
57
+
58
+ export interface AgentToolsDependencies {
59
+ access: LinearAccess;
60
+ dataDirectory: string;
61
+ readSettings(): Promise<LinearSettings | null>;
62
+ }
63
+
64
+ /** The tool settings for a project: its own values, then the values for all projects. */
65
+ export function toolSettings(values: LinearSettings | null, projectId: string | null | undefined): ToolSettings {
66
+ return projectDefaults(values ?? linearSettings.schema.parse({}), projectId).tools;
67
+ }
68
+
69
+ /** The root issue or one of its sub-issues; any other key is refused. */
70
+ async function treeIssue(linear: LinearService, scope: ToolScope, key: string | undefined) {
71
+ const wanted = (key ?? scope.home).toUpperCase();
72
+ const root = await linear.getIssue(scope.root);
73
+ if (wanted === root.identifier.toUpperCase()) return root;
74
+ if (!root.children.some((child) => child.identifier.toUpperCase() === wanted)) {
75
+ throw new Error(`${wanted} is not ${root.identifier} or one of its sub-issues.`);
76
+ }
77
+ return linear.getIssue(wanted);
78
+ }
79
+
80
+ export function linearTools(deps: AgentToolsDependencies, paseo: () => Paseo, mint: Mint): McpTool<Caller>[] {
81
+ const callers = new Map<string, string>();
82
+ async function callerAgent(api: Paseo, grant: Grant): Promise<string | null> {
83
+ const known = callers.get(grant.id);
84
+ if (known) return known;
85
+ const result = await api.agents.list({ filter: { labels: { [AGENT_LABELS.tools]: grant.id } }, page: { limit: 1 } });
86
+ const id = result.entries[0]?.agent.id ?? null;
87
+ if (id) callers.set(grant.id, id);
88
+ return id;
89
+ }
90
+ // Agents of one launch that still work count against the limit of the caller's project.
91
+ async function checkLimit(api: Paseo, caller: Caller) {
92
+ const limit = caller.tools.maxAgents;
93
+ if (limit <= 0) return;
94
+ const { entries } = await api.agents.list({ filter: { labels: { [AGENT_LABELS.startedBy]: caller.id } }, page: { limit: 100 } });
95
+ const running = entries.filter((entry) => !entry.agent.archivedAt && RUNNING.has(entry.agent.status)).length;
96
+ if (running >= limit) {
97
+ throw new Error(`${running} of your agents are working, and the limit is ${limit}. Call wait_agent for one of them, then start the next one.`);
98
+ }
99
+ }
100
+ return [
101
+ tool({
102
+ name: "read_issue",
103
+ description: "Read a Linear issue of your issue tree: status, description with its task list, and sub-issues.",
104
+ input: ReadIssueInput,
105
+ async run(input, grant) {
106
+ const { linear } = await deps.access.connect(grant.scope.keyScope);
107
+ return issueText(await treeIssue(linear, grant.scope, input.issue));
108
+ },
109
+ }),
110
+ tool({
111
+ name: "edit_issue",
112
+ description:
113
+ "Change the description of a Linear issue of your issue tree. Replace exact text, for example `- [ ] Add the form` with `- [x] Add the form` to check off a task list item.",
114
+ input: EditIssueInput,
115
+ allowed: (caller) => caller.tools.allowEdits,
116
+ run(input, grant) {
117
+ return deps.access.mutate(grant.scope.keyScope, async (linear) => {
118
+ const issue = await treeIssue(linear, grant.scope, input.issue);
119
+ const edited = editText(issue.description ?? "", input.old_text, input.new_text);
120
+ if ("error" in edited) throw new Error(edited.error);
121
+ await linear.updateIssue(issue.id, { description: edited.body });
122
+ const tasks = taskCount(edited.body);
123
+ const count = tasks.total > 0 ? ` ${tasks.done} of ${tasks.total} task list items are checked.` : "";
124
+ return `Updated the description of ${issue.identifier}.${count}`;
125
+ });
126
+ },
127
+ }),
128
+ tool({
129
+ name: "start_agent",
130
+ description:
131
+ "Start a Paseo agent on a sub-issue, in your workspace. It runs on the agent the user chose for the sub-issue, or on your provider and model. It gets the Linear tools. Returns its id at once.",
132
+ input: StartAgentInput,
133
+ allowed: (grant) => grant.canStart,
134
+ async run(input, grant) {
135
+ const api = paseo();
136
+ const key = input.issue.toUpperCase();
137
+ const { linear } = await deps.access.connect(grant.scope.keyScope);
138
+ await treeIssue(linear, grant.scope, key);
139
+ await checkLimit(api, grant);
140
+ const parent = await callerAgent(api, grant);
141
+ const child = await mint({ ...grant.scope, home: key }, false);
142
+ const { keyScope } = grant.scope;
143
+ const create = (config: AgentConfig) =>
144
+ api.workspaces.ref(grant.scope.workspaceId).agents.create({
145
+ config: { ...config, mcpServers: { [TOOLS_SERVER]: child.server }, toolPolicy: child.toolPolicy },
146
+ ...(parent ? { parent } : {}),
147
+ title: input.title,
148
+ prompt: `${childBrief(key, grant.scope.root, grant.tools.allowEdits)}\n\n${input.prompt}`,
149
+ labels: {
150
+ [AGENT_LABELS.issue]: key,
151
+ [AGENT_LABELS.action]: "implement",
152
+ ...(keyScope ? { [AGENT_LABELS.project]: keyScope } : {}),
153
+ [AGENT_LABELS.tools]: child.grantId,
154
+ [AGENT_LABELS.startedBy]: grant.id,
155
+ },
156
+ });
157
+ // The user's choice for the sub-issue wins over the caller's thinking.
158
+ const assigned = assignmentFor(grant.scope.assignments, key);
159
+ const base = assigned?.config ?? grant.scope.config;
160
+ const thinking = assigned?.config.thinkingOptionId ? undefined : input.thinking;
161
+ let note = assigned ? ` It runs on ${assigned.label}, the agent the user chose for ${key}.` : "";
162
+ if (assigned?.config.thinkingOptionId && input.thinking) note += " Its thinking is set too, so yours is not used.";
163
+ let handle: Awaited<ReturnType<typeof create>>;
164
+ try {
165
+ handle = await create(thinking ? { ...base, thinkingOptionId: thinking } : base);
166
+ } catch (error) {
167
+ if (!thinking) throw error;
168
+ handle = await create(base);
169
+ note += ` The model has no thinking option "${thinking}", so it uses ${assigned ? "its default" : "yours"}.`;
170
+ }
171
+ return `Started agent ${handle.id} on ${key}.${note} Call wait_agent with agent_id "${handle.id}" to get its result.`;
172
+ },
173
+ }),
174
+ tool({
175
+ name: "wait_agent",
176
+ description: "Wait for an agent that start_agent started, and get its last reply. Call it again while the agent works.",
177
+ input: WaitAgentInput,
178
+ allowed: (grant) => grant.canStart,
179
+ async run(input, grant) {
180
+ const id = input.agent_id;
181
+ const handle = paseo().agents.ref(id);
182
+ const snapshot = (await handle.refresh().catch(() => null))?.agent;
183
+ if (snapshot?.labels?.[AGENT_LABELS.startedBy] !== grant.id) {
184
+ throw new Error(`Agent ${id} was not started by your start_agent calls.`);
185
+ }
186
+ const result = await handle.waitForFinish(input.timeout_seconds * 1000);
187
+ if (result.status === "timeout") return `Agent ${id} is still working. Call wait_agent again.`;
188
+ if (result.status === "permission") return `Agent ${id} waits for the user to answer a permission request in Paseo.`;
189
+ if (result.status === "error") return `Agent ${id} stopped with an error: ${result.error ?? "unknown error"}`;
190
+ const reply = result.lastMessage ?? (await lastReply(handle));
191
+ return `Agent ${id} finished.\n\nIts last reply:\n${reply || "(no reply)"}`;
192
+ },
193
+ }),
194
+ ];
195
+ }
196
+
197
+ type Minted = z.infer<typeof agentToolsRpc.output>;
198
+ type Mint = (scope: ToolScope, canStart: boolean) => Promise<Minted>;
199
+
200
+ export function registerAgentTools(server: PluginServerContext, deps: AgentToolsDependencies) {
201
+ const file = createJsonFile(path.join(deps.dataDirectory, "agent-tools.json"), FileSchema, () => ({
202
+ port: null,
203
+ grants: [],
204
+ }));
205
+ // The SDK hands Paseo to handlers and hooks only. The subprocess has one, so keep the latest.
206
+ let api: Paseo | null = null;
207
+ const remember = (_event: unknown, context: { paseo: Paseo }) => {
208
+ api = context.paseo;
209
+ };
210
+ server.on("agent.turn_started", remember);
211
+ server.on("agent.turn_ended", remember);
212
+ server.on("agent.created", remember);
213
+ const paseo = () => {
214
+ if (!api) throw new Error("Paseo is not ready yet. Try again in a few seconds.");
215
+ return api;
216
+ };
217
+
218
+ let listening: Promise<number> | null = null;
219
+ const ensureListening = () => {
220
+ listening ??= file
221
+ .read()
222
+ .then((current) => listenLocal(http, current.port))
223
+ .then(async (port) => {
224
+ await file.update((current) => ({ next: { ...current, port }, result: null }));
225
+ return port;
226
+ });
227
+ return listening;
228
+ };
229
+ const mint: Mint = async (scope, canStart) => {
230
+ if (canStart && !toolSettings(await deps.readSettings(), scope.projectId).enabled) throw new Error(TOOLS_OFF);
231
+ const port = await ensureListening();
232
+ const token = randomBytes(32).toString("base64url");
233
+ const grant: Grant = {
234
+ id: randomBytes(8).toString("hex"),
235
+ hash: hashOf(token),
236
+ scope,
237
+ canStart,
238
+ createdAt: new Date().toISOString(),
239
+ };
240
+ await file.update((current) => ({ next: { ...current, grants: [...current.grants, grant].slice(-MAX_GRANTS) }, result: null }));
241
+ return {
242
+ grantId: grant.id,
243
+ server: { type: "http", url: `http://127.0.0.1:${port}/mcp`, headers: { Authorization: `Bearer ${token}` } },
244
+ toolPolicy: toolPolicy(canStart ? [...LINEAR_TOOLS, ...AGENT_TOOLS] : [...LINEAR_TOOLS]),
245
+ };
246
+ };
247
+ // Settings are read on each request, so a change applies to agents that already run.
248
+ const http = createMcpServer<Caller>({
249
+ name: "paseo-linear",
250
+ version: "1.0.0",
251
+ tools: linearTools(deps, paseo, mint),
252
+ async authorize(token) {
253
+ const hash = hashOf(token);
254
+ const grant = (await file.read()).grants.find((entry) => entry.hash === hash);
255
+ if (!grant) return null;
256
+ return { ...grant, tools: toolSettings(await deps.readSettings(), grant.scope.projectId) };
257
+ },
258
+ closed: (caller) => (caller.tools.enabled ? null : TOOLS_OFF),
259
+ });
260
+
261
+ server.handle(agentToolsRpc, async (scope, context) => {
262
+ api = context.paseo;
263
+ return mint(scope, true);
264
+ });
265
+ // Agents from before a restart call the same port, so listen again when grants exist.
266
+ void file
267
+ .read()
268
+ .then((current) => (current.grants.length > 0 ? ensureListening() : null))
269
+ .catch((error: unknown) => console.error("Linear MCP could not start", error));
270
+
271
+ return () =>
272
+ new Promise<void>((resolve) => {
273
+ http.closeAllConnections();
274
+ http.close(() => resolve());
275
+ });
276
+ }
@@ -37,7 +37,16 @@ function listKey(query: IssueQuery) {
37
37
  return { teamId, assignee, status, query: text.trim(), sort, after };
38
38
  }
39
39
 
40
- export function registerHandlers(server: PluginServerContext, dependencies: HandlerDependencies) {
40
+ /** Linear access for the sync, explore, and setup handlers. */
41
+ export interface LinearAccess {
42
+ connect(projectId: ProjectId): Promise<{ linear: LinearService; fingerprint: string }>;
43
+ mutate<T>(projectId: ProjectId, run: (linear: LinearService) => Promise<T>): Promise<T>;
44
+ }
45
+
46
+ export function registerHandlers(
47
+ server: PluginServerContext,
48
+ dependencies: HandlerDependencies,
49
+ ): LinearAccess {
41
50
  const { credentials, endpoint } = dependencies;
42
51
  const cache =
43
52
  dependencies.cache ?? createResponseCache({ maxEntries: 300, maxAgeMs: 24 * 60 * 60 * 1000 });
@@ -169,4 +178,6 @@ export function registerHandlers(server: PluginServerContext, dependencies: Hand
169
178
  return { attached: false };
170
179
  }
171
180
  });
181
+
182
+ return { connect, mutate };
172
183
  }
package/server/init.ts ADDED
@@ -0,0 +1,89 @@
1
+ import type { PluginServerContext } from "@getpaseo/plugin/server";
2
+ import { knowledgeLines, type ProjectKnowledge } from "../shared/knowledge";
3
+ import { type InitProposal, InitProposalSchema, initStartRpc, initStatusRpc } from "../shared/live";
4
+ import { AGENT_LABELS } from "../shared/prompts";
5
+ import type { LinearSettings } from "../shared/settings";
6
+ import { createJobStore, READ_ONLY_RULES, startInternalRun } from "./agent-runs";
7
+ import type { KnowledgeService } from "./knowledge";
8
+
9
+ export function initPrompt(rootPath: string, knowledge: ProjectKnowledge | null = null): string {
10
+ const map = knowledgeLines(knowledge);
11
+ const areas = knowledge?.areas.length ? knowledge.areas : null;
12
+ return [
13
+ `Set up Linear agent prompts for the repository at ${rootPath}.`,
14
+ "",
15
+ ...READ_ONLY_RULES,
16
+ "",
17
+ ...(map.length > 0 ? [...map, ""] : []),
18
+ "Read the guidance the repository gives agents and contributors: AGENTS.md, CLAUDE.md and",
19
+ "similar files, CONTRIBUTING, the README, package scripts or build files, CI workflows, pull",
20
+ "request templates, commit conventions, and the test setup.",
21
+ "",
22
+ "Then write:",
23
+ "- instructions: up to 12 short lines an agent must follow here: commands, conventions, limits.",
24
+ '- steps: 3 to 8 short checks an agent runs before it finishes, such as "Run npm run check".',
25
+ "- implement: extra guidance for implementing an issue in this repository, or an empty string.",
26
+ "- review: extra guidance for reviewing a change in this repository, or an empty string.",
27
+ "- summary: one line on what the project is and does.",
28
+ areas
29
+ ? "- areas: for each area in the project map, one line on what it holds, keyed by its path."
30
+ : "- areas: an empty list.",
31
+ "Use only facts you found in the repository. Do not repeat generic advice.",
32
+ "",
33
+ "Reply with only a JSON object in a ```json block:",
34
+ '{"instructions":"...","steps":["..."],"implement":"...","review":"...","summary":"...",',
35
+ ` "areas":[${areas ? `{"path":"${areas[0]?.path ?? ""}","summary":"..."}` : ""}]}`,
36
+ ].join("\n");
37
+ }
38
+
39
+ export interface InitDependencies {
40
+ readSettings(): Promise<LinearSettings | null>;
41
+ knowledge: KnowledgeService;
42
+ }
43
+
44
+ export function registerInit(server: PluginServerContext, dependencies: InitDependencies) {
45
+ const jobs = createJobStore<InitProposal>();
46
+
47
+ server.handle(initStartRpc, async ({ projectId, rootPath }, { paseo }) => {
48
+ // Only known Paseo projects can be set up, so the client cannot point an agent anywhere.
49
+ const { projects } = await paseo.projects.list();
50
+ const project = projects.find((entry) => entry.projectId === projectId);
51
+ if (!project || project.projectRootPath !== rootPath) {
52
+ throw new Error("That project is not on this host");
53
+ }
54
+ const settings = await dependencies.readSettings();
55
+ const { knowledge } = dependencies;
56
+ const job = await jobs.start(null, async () => {
57
+ // Setup always inspects again, so the agent starts from the current files.
58
+ const known = await knowledge.inspect({ projectId, rootPath }).catch((error: unknown) => {
59
+ console.error(`Linear could not inspect project ${projectId}`, error);
60
+ return null;
61
+ });
62
+ const run = await startInternalRun({
63
+ paseo,
64
+ workspace: await paseo.workspaces.open(rootPath),
65
+ cwd: rootPath,
66
+ provider: settings?.live.exploreProvider || settings?.launch.provider || "",
67
+ effort: settings?.live.exploreProvider ? settings.live.exploreEffort : "",
68
+ title: `Set up Linear prompts for ${project.projectDisplayName}`,
69
+ prompt: initPrompt(rootPath, known),
70
+ labels: { [AGENT_LABELS.init]: projectId },
71
+ schema: InitProposalSchema,
72
+ timeoutMs: 20 * 60_000,
73
+ });
74
+ const result = run.result.then(async (proposal) => {
75
+ await knowledge.annotate(projectId, proposal).catch((error: unknown) =>
76
+ console.error(`Linear could not save what setup learned about ${projectId}`, error),
77
+ );
78
+ return proposal;
79
+ });
80
+ return { agentId: run.agentId, result };
81
+ });
82
+ return { job };
83
+ });
84
+
85
+ server.handle(initStatusRpc, async ({ jobId }) => {
86
+ const record = jobs.get(jobId);
87
+ return { job: record?.job ?? null, proposal: record?.value ?? null };
88
+ });
89
+ }