@yannelli/paseo-linear-plugin 0.0.0-stage → 0.3.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 (49) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +13 -0
  3. package/OVERVIEW.md +33 -0
  4. package/README.md +207 -2
  5. package/client/agent-options.ts +200 -0
  6. package/client/breadcrumbs.tsx +110 -0
  7. package/client/browser.tsx +275 -0
  8. package/client/compat.ts +107 -0
  9. package/client/connect.tsx +136 -0
  10. package/client/create-issue.tsx +291 -0
  11. package/client/glyphs.tsx +117 -0
  12. package/client/issue-detail.tsx +366 -0
  13. package/client/issue-list.tsx +373 -0
  14. package/client/issue-row.tsx +152 -0
  15. package/client/issue-sections.tsx +325 -0
  16. package/client/issue-tree.ts +132 -0
  17. package/client/key-scope.tsx +23 -0
  18. package/client/launch-choices.ts +125 -0
  19. package/client/launch-fields.tsx +152 -0
  20. package/client/launch-plan.ts +227 -0
  21. package/client/launch.tsx +484 -0
  22. package/client/markdown.tsx +240 -0
  23. package/client/model-browser.tsx +296 -0
  24. package/client/pickers.tsx +238 -0
  25. package/client/provider-icon.tsx +78 -0
  26. package/client/queries.ts +211 -0
  27. package/client/settings-connection.tsx +109 -0
  28. package/client/settings-fields.tsx +173 -0
  29. package/client/settings-keys.tsx +117 -0
  30. package/client/settings-projects.tsx +281 -0
  31. package/client/settings-prompts.tsx +198 -0
  32. package/client/store.ts +71 -0
  33. package/client/timeline-card.tsx +63 -0
  34. package/client/ui.tsx +352 -0
  35. package/index.client.tsx +111 -0
  36. package/index.server.ts +12 -0
  37. package/package.json +51 -4
  38. package/paseo-plugin.json +7 -0
  39. package/server/cache.ts +68 -0
  40. package/server/credentials.ts +101 -0
  41. package/server/graphql.ts +68 -0
  42. package/server/handlers.ts +172 -0
  43. package/server/linear.ts +130 -0
  44. package/server/queries.ts +280 -0
  45. package/shared/issues.ts +31 -0
  46. package/shared/linear.ts +268 -0
  47. package/shared/markdown.ts +416 -0
  48. package/shared/prompts.ts +150 -0
  49. package/shared/settings.ts +68 -0
@@ -0,0 +1,111 @@
1
+ import type { PluginClientContext } from "@getpaseo/plugin/client";
2
+ import { LinearPanel, LinearScreen } from "./client/browser";
3
+ import { openPluginScreen, registerScreen } from "./client/compat";
4
+ import { ConnectionSettings } from "./client/settings-connection";
5
+ import { ProjectSettings } from "./client/settings-projects";
6
+ import { PromptSettings } from "./client/settings-prompts";
7
+ import { focusIssue, panelScope, SCREEN_SCOPE, updateBrowser } from "./client/store";
8
+ import { IssueCardRow } from "./client/timeline-card";
9
+ import { issueAttachments } from "./shared/issues";
10
+ import { ISSUE_CARD_KIND, IssueCardSchema, LINEAR_IDENTIFIER } from "./shared/linear";
11
+
12
+ const SCREEN_ID = "linear";
13
+ const PANEL_ID = "issues";
14
+
15
+ export default function contribute(client: PluginClientContext) {
16
+ client.addAttachmentSource(issueAttachments);
17
+ registerScreen(client, {
18
+ id: SCREEN_ID,
19
+ title: "Linear",
20
+ icon: "SquareKanban",
21
+ Component: LinearScreen,
22
+ });
23
+ client.addWorkspacePanel({
24
+ id: PANEL_ID,
25
+ title: "Linear",
26
+ icon: "SquareKanban",
27
+ context: "workspace",
28
+ locations: ["workspace", "explorer"],
29
+ Component: LinearPanel,
30
+ });
31
+ client.addSettingsScreen({
32
+ id: "connection",
33
+ title: "Account",
34
+ icon: "KeyRound",
35
+ Component: ConnectionSettings,
36
+ });
37
+ client.addSettingsScreen({
38
+ id: "prompts",
39
+ title: "Prompts",
40
+ icon: "MessageSquare",
41
+ Component: PromptSettings,
42
+ });
43
+ client.addSettingsScreen({
44
+ id: "projects",
45
+ title: "Projects",
46
+ icon: "FolderGit2",
47
+ Component: ProjectSettings,
48
+ });
49
+ client.addCommandCenterItem({
50
+ id: "open-issues",
51
+ title: "Linear: Open issues",
52
+ icon: "SquareKanban",
53
+ keywords: ["linear", "issues", "tickets"],
54
+ context: "global",
55
+ onSelect(context) {
56
+ openPluginScreen(context, SCREEN_ID);
57
+ },
58
+ });
59
+ client.addCommandCenterItem({
60
+ id: "create-issue",
61
+ title: "Linear: Create issue",
62
+ icon: "Plus",
63
+ keywords: ["linear", "new", "ticket"],
64
+ context: "global",
65
+ onSelect(context) {
66
+ updateBrowser(SCREEN_SCOPE, { creating: true });
67
+ openPluginScreen(context, SCREEN_ID);
68
+ },
69
+ });
70
+ client.addCommandCenterItem({
71
+ id: "workspace-issues",
72
+ title: "Linear: Show issues in this workspace",
73
+ icon: "SquareKanban",
74
+ keywords: ["linear", "panel"],
75
+ context: "workspace",
76
+ onSelect({ openPanel }) {
77
+ openPanel(PANEL_ID);
78
+ },
79
+ });
80
+ client.addCommandCenterItem({
81
+ id: "settings",
82
+ title: "Linear: Settings",
83
+ icon: "Settings",
84
+ keywords: ["linear", "api key", "prompts", "projects"],
85
+ context: "global",
86
+ onSelect({ openSettings }) {
87
+ openSettings("connection");
88
+ },
89
+ });
90
+ client.addSlashCommand({
91
+ name: "linear",
92
+ description: "Open Linear issues, or jump to one by key",
93
+ argumentHint: "[ENG-123 or search]",
94
+ context: "workspace",
95
+ onSubmit({ args, workspace, openPanel }) {
96
+ const scope = panelScope(workspace.id);
97
+ const text = args.trim();
98
+ if (LINEAR_IDENTIFIER.test(text)) focusIssue(scope, text.toUpperCase());
99
+ else if (text)
100
+ updateBrowser(scope, { query: text, issueId: null, assignee: "anyone", status: "all" });
101
+ openPanel(PANEL_ID);
102
+ },
103
+ });
104
+ client.addTimelineRenderer({
105
+ kind: ISSUE_CARD_KIND,
106
+ version: 1,
107
+ schema: IssueCardSchema,
108
+ Component: IssueCardRow,
109
+ });
110
+ return () => {};
111
+ }
@@ -0,0 +1,12 @@
1
+ import type { PluginServerContext } from "@getpaseo/plugin/server";
2
+ import { createCredentialStore, defaultCredentialPath } from "./server/credentials";
3
+ import { registerHandlers } from "./server/handlers";
4
+ import { linearSettings } from "./shared/settings";
5
+
6
+ export default function contribute(server: PluginServerContext) {
7
+ server.registerSettings(linearSettings);
8
+ registerHandlers(server, {
9
+ credentials: createCredentialStore({ file: defaultCredentialPath() }),
10
+ });
11
+ return () => {};
12
+ }
package/package.json CHANGED
@@ -1,6 +1,53 @@
1
1
  {
2
2
  "name": "@yannelli/paseo-linear-plugin",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.0",
4
+ "description": "Browse and triage Linear issues in Paseo and start agents from them",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "keywords": [
8
+ "paseo",
9
+ "paseo-plugin",
10
+ "linear"
11
+ ],
12
+ "engines": {
13
+ "node": ">=24"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/yannelli/paseo-linear-plugin.git"
18
+ },
19
+ "homepage": "https://github.com/yannelli/paseo-linear-plugin#readme",
20
+ "bugs": {
21
+ "url": "https://github.com/yannelli/paseo-linear-plugin/issues"
22
+ },
23
+ "files": [
24
+ "paseo-plugin.json",
25
+ "OVERVIEW.md",
26
+ "NOTICE",
27
+ "index.client.tsx",
28
+ "index.server.ts",
29
+ "client/",
30
+ "server/",
31
+ "shared/"
32
+ ],
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "scripts": {
37
+ "typecheck": "tsc --noEmit",
38
+ "test": "vitest run && node --test test/*.test.mjs",
39
+ "check": "npm run typecheck && npm test",
40
+ "release:dry-run": "node scripts/release.mjs --dry-run"
41
+ },
42
+ "devDependencies": {
43
+ "@getpaseo/plugin": "0.10.3",
44
+ "@tanstack/react-query": "^5.90.11",
45
+ "@types/node": "^24.19.1",
46
+ "@types/react": "~19.2.0",
47
+ "react": "19.1.0",
48
+ "react-native": "0.81.5",
49
+ "typescript": "^5.9.3",
50
+ "vitest": "^4.1.6",
51
+ "zod": "^4.4.3"
52
+ }
53
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "id": "linear",
3
+ "description": "Browse, triage, and create Linear issues, and start agents that implement or review them",
4
+ "requirements": {
5
+ "paseo": ">=0.10.0"
6
+ }
7
+ }
@@ -0,0 +1,68 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ export interface CachedValue<T> {
4
+ value: T;
5
+ fetchedAt: number;
6
+ }
7
+
8
+ export interface ResponseCache {
9
+ get<T>(key: string): CachedValue<T> | null;
10
+ set(key: string, value: unknown): void;
11
+ /** Drops every entry whose key starts with `prefix`. */
12
+ drop(prefix: string): void;
13
+ clear(): void;
14
+ size(): number;
15
+ }
16
+
17
+ // Entries are partitioned by a hash of the API key, so two Linear workspaces never share data.
18
+ export function keyFingerprint(apiKey: string): string {
19
+ return createHash("sha256").update(apiKey).digest("hex").slice(0, 16);
20
+ }
21
+
22
+ export function cacheKey(fingerprint: string, kind: string, params: unknown = null): string {
23
+ return `${fingerprint}:${kind}:${JSON.stringify(params)}`;
24
+ }
25
+
26
+ export function createResponseCache(options: {
27
+ maxEntries: number;
28
+ maxAgeMs: number;
29
+ now?: () => number;
30
+ }): ResponseCache {
31
+ const now = options.now ?? Date.now;
32
+ const entries = new Map<string, CachedValue<unknown>>();
33
+
34
+ return {
35
+ get<T>(key: string) {
36
+ const entry = entries.get(key);
37
+ if (!entry) return null;
38
+ if (now() - entry.fetchedAt > options.maxAgeMs) {
39
+ entries.delete(key);
40
+ return null;
41
+ }
42
+ // Map order is insertion order; re-inserting marks the entry as recently used.
43
+ entries.delete(key);
44
+ entries.set(key, entry);
45
+ return entry as CachedValue<T>;
46
+ },
47
+ set(key, value) {
48
+ entries.delete(key);
49
+ entries.set(key, { value, fetchedAt: now() });
50
+ while (entries.size > options.maxEntries) {
51
+ const oldest = entries.keys().next().value;
52
+ if (oldest === undefined) break;
53
+ entries.delete(oldest);
54
+ }
55
+ },
56
+ drop(prefix) {
57
+ for (const key of [...entries.keys()]) {
58
+ if (key.startsWith(prefix)) entries.delete(key);
59
+ }
60
+ },
61
+ clear() {
62
+ entries.clear();
63
+ },
64
+ size() {
65
+ return entries.size;
66
+ },
67
+ };
68
+ }
@@ -0,0 +1,101 @@
1
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+ import { z } from "zod";
5
+ import type { KeySource } from "../shared/linear";
6
+
7
+ // Version 1 files hold only `apiKey`. Version 2 adds keys for individual Paseo projects.
8
+ const CredentialFileSchema = z.object({
9
+ apiKey: z.string().min(1).optional(),
10
+ projects: z.record(z.string(), z.object({ apiKey: z.string().min(1) })).default({}),
11
+ });
12
+ type CredentialFile = z.infer<typeof CredentialFileSchema>;
13
+
14
+ export interface ResolvedCredential {
15
+ apiKey: string;
16
+ source: KeySource;
17
+ }
18
+
19
+ export interface CredentialStore {
20
+ /** Project key, then the saved default key, then LINEAR_API_KEY. */
21
+ resolve(projectId?: string | null): Promise<ResolvedCredential | null>;
22
+ save(apiKey: string, projectId?: string | null): Promise<void>;
23
+ clear(projectId?: string | null): Promise<void>;
24
+ projectKeys(): Promise<{ projectId: string; apiKey: string }[]>;
25
+ }
26
+
27
+ export function defaultCredentialPath(env: NodeJS.ProcessEnv = process.env): string {
28
+ const home = env.PASEO_HOME?.trim() || path.join(homedir(), ".paseo");
29
+ return path.join(home, "plugin-data", "linear", "credentials.json");
30
+ }
31
+
32
+ export function createCredentialStore(options: {
33
+ file: string;
34
+ env?: NodeJS.ProcessEnv;
35
+ }): CredentialStore {
36
+ const env = options.env ?? process.env;
37
+
38
+ async function read(): Promise<CredentialFile> {
39
+ try {
40
+ const parsed = CredentialFileSchema.safeParse(
41
+ JSON.parse(await readFile(options.file, "utf8")),
42
+ );
43
+ return parsed.success ? parsed.data : { projects: {} };
44
+ } catch {
45
+ return { projects: {} };
46
+ }
47
+ }
48
+
49
+ async function write(next: CredentialFile): Promise<void> {
50
+ if (!next.apiKey && Object.keys(next.projects).length === 0) {
51
+ await rm(options.file, { force: true });
52
+ return;
53
+ }
54
+ await mkdir(path.dirname(options.file), { recursive: true, mode: 0o700 });
55
+ const temporary = `${options.file}.${process.pid}.tmp`;
56
+ await writeFile(temporary, JSON.stringify(next), { mode: 0o600 });
57
+ await chmod(temporary, 0o600);
58
+ await rename(temporary, options.file);
59
+ }
60
+
61
+ return {
62
+ async resolve(projectId) {
63
+ const stored = await read();
64
+ const projectKey = projectId ? stored.projects[projectId]?.apiKey : undefined;
65
+ if (projectKey) return { apiKey: projectKey, source: "project" };
66
+ if (stored.apiKey) return { apiKey: stored.apiKey, source: "file" };
67
+ const fromEnv = env.LINEAR_API_KEY?.trim();
68
+ return fromEnv ? { apiKey: fromEnv, source: "environment" } : null;
69
+ },
70
+ async save(apiKey, projectId) {
71
+ const stored = await read();
72
+ const key = apiKey.trim();
73
+ if (projectId) {
74
+ await write({ ...stored, projects: { ...stored.projects, [projectId]: { apiKey: key } } });
75
+ } else {
76
+ await write({ ...stored, apiKey: key });
77
+ }
78
+ },
79
+ async clear(projectId) {
80
+ const stored = await read();
81
+ if (projectId) {
82
+ const projects = { ...stored.projects };
83
+ delete projects[projectId];
84
+ await write({ ...stored, projects });
85
+ } else {
86
+ await write({ projects: stored.projects });
87
+ }
88
+ },
89
+ async projectKeys() {
90
+ const stored = await read();
91
+ return Object.entries(stored.projects).map(([projectId, entry]) => ({
92
+ projectId,
93
+ apiKey: entry.apiKey,
94
+ }));
95
+ },
96
+ };
97
+ }
98
+
99
+ export function keyHint(apiKey: string): string {
100
+ return apiKey.length > 8 ? `…${apiKey.slice(-4)}` : "…";
101
+ }
@@ -0,0 +1,68 @@
1
+ import { z } from "zod";
2
+
3
+ export const LINEAR_ENDPOINT = "https://api.linear.app/graphql";
4
+
5
+ export class LinearApiError extends Error {
6
+ constructor(message: string) {
7
+ super(message);
8
+ this.name = "LinearApiError";
9
+ }
10
+ }
11
+
12
+ export interface LinearGraphqlOptions {
13
+ apiKey: string;
14
+ endpoint?: string;
15
+ request?: typeof fetch;
16
+ }
17
+
18
+ export type LinearGraphql = (
19
+ query: string,
20
+ variables?: Record<string, unknown>,
21
+ ) => Promise<unknown>;
22
+
23
+ const GraphqlEnvelopeSchema = z.object({
24
+ data: z.unknown().optional(),
25
+ errors: z.array(z.object({ message: z.string() }).passthrough()).optional(),
26
+ });
27
+
28
+ function describeHttpFailure(status: number): string {
29
+ if (status === 401 || status === 403) return "Linear rejected the API key";
30
+ if (status === 429) return "Linear rate limit reached. Try again shortly";
31
+ return `Linear API request failed with HTTP ${status}`;
32
+ }
33
+
34
+ export function createLinearGraphql(options: LinearGraphqlOptions): LinearGraphql {
35
+ const apiKey = options.apiKey.trim();
36
+ if (!apiKey) throw new LinearApiError("Connect Linear: add an API key in Linear settings");
37
+ const endpoint = options.endpoint ?? LINEAR_ENDPOINT;
38
+ const request = options.request ?? fetch;
39
+
40
+ return async (query, variables = {}) => {
41
+ const response = await request(endpoint, {
42
+ method: "POST",
43
+ headers: { Authorization: apiKey, "Content-Type": "application/json" },
44
+ body: JSON.stringify({ query, variables }),
45
+ });
46
+ // Linear reports auth and validation failures as HTTP 400 with GraphQL errors.
47
+ const text = await response.text();
48
+ let body: z.infer<typeof GraphqlEnvelopeSchema> | null = null;
49
+ try {
50
+ body = GraphqlEnvelopeSchema.parse(JSON.parse(text));
51
+ } catch {
52
+ body = null;
53
+ }
54
+ if (body?.errors && body.errors.length > 0) {
55
+ const message = body.errors.map((error) => error.message).join("; ");
56
+ throw new LinearApiError(
57
+ response.status === 401 || response.status === 403
58
+ ? describeHttpFailure(response.status)
59
+ : message,
60
+ );
61
+ }
62
+ if (!response.ok) throw new LinearApiError(describeHttpFailure(response.status));
63
+ if (!body || body.data === undefined || body.data === null) {
64
+ throw new LinearApiError("Linear returned no data");
65
+ }
66
+ return body.data;
67
+ };
68
+ }
@@ -0,0 +1,172 @@
1
+ import type { PluginServerContext } from "@getpaseo/plugin/server";
2
+ import {
3
+ attachIssueCardRpc,
4
+ authClearRpc,
5
+ authSaveRpc,
6
+ authStatusRpc,
7
+ cachedCatalogRpc,
8
+ cachedIssueRpc,
9
+ cachedIssuesRpc,
10
+ catalogRpc,
11
+ createCommentRpc,
12
+ createIssueRpc,
13
+ getIssueRpc,
14
+ ISSUE_CARD_KIND,
15
+ type IssueQuery,
16
+ listIssuesRpc,
17
+ updateIssueRpc,
18
+ } from "../shared/linear";
19
+ import { searchIssuesRpc } from "../shared/issues";
20
+ import { cacheKey, createResponseCache, keyFingerprint, type ResponseCache } from "./cache";
21
+ import { type CredentialStore, keyHint } from "./credentials";
22
+ import { createLinearGraphql, LinearApiError } from "./graphql";
23
+ import { createLinearIssueSearch } from "./linear";
24
+ import { createLinearService, type LinearService } from "./queries";
25
+
26
+ export interface HandlerDependencies {
27
+ credentials: CredentialStore;
28
+ endpoint?: string;
29
+ cache?: ResponseCache;
30
+ }
31
+
32
+ type ProjectId = string | null | undefined;
33
+
34
+ // The cache key ignores the key scope and cursor fields that do not change the response.
35
+ function listKey(query: IssueQuery) {
36
+ const { teamId, assignee, status, query: text, sort = "updated", after } = query;
37
+ return { teamId, assignee, status, query: text.trim(), sort, after };
38
+ }
39
+
40
+ export function registerHandlers(server: PluginServerContext, dependencies: HandlerDependencies) {
41
+ const { credentials, endpoint } = dependencies;
42
+ const cache =
43
+ dependencies.cache ?? createResponseCache({ maxEntries: 300, maxAgeMs: 24 * 60 * 60 * 1000 });
44
+
45
+ async function apiKey(projectId?: ProjectId): Promise<string> {
46
+ const credential = await credentials.resolve(projectId);
47
+ if (!credential) throw new LinearApiError("Connect Linear: add an API key in Linear settings");
48
+ return credential.apiKey;
49
+ }
50
+
51
+ async function connect(projectId: ProjectId) {
52
+ const key = await apiKey(projectId);
53
+ return {
54
+ linear: createLinearService(createLinearGraphql({ apiKey: key, endpoint })),
55
+ fingerprint: keyFingerprint(key),
56
+ };
57
+ }
58
+
59
+ /** Returns the cached response for a key scope without calling Linear. */
60
+ async function readCache<T>(projectId: ProjectId, kind: string, params: unknown) {
61
+ const credential = await credentials.resolve(projectId);
62
+ const entry = credential
63
+ ? cache.get<T>(cacheKey(keyFingerprint(credential.apiKey), kind, params))
64
+ : null;
65
+ return entry
66
+ ? { value: entry.value, fetchedAt: new Date(entry.fetchedAt).toISOString() }
67
+ : { value: null, fetchedAt: null };
68
+ }
69
+
70
+ async function fetchAndStore<T>(
71
+ projectId: ProjectId,
72
+ kind: string,
73
+ params: unknown,
74
+ load: (linear: LinearService) => Promise<T>,
75
+ ): Promise<T> {
76
+ const { linear, fingerprint } = await connect(projectId);
77
+ const value = await load(linear);
78
+ cache.set(cacheKey(fingerprint, kind, params), value);
79
+ return value;
80
+ }
81
+
82
+ // Edits change list order and issue details, so every list and issue entry for the key goes.
83
+ async function mutate<T>(projectId: ProjectId, run: (linear: LinearService) => Promise<T>) {
84
+ const { linear, fingerprint } = await connect(projectId);
85
+ try {
86
+ return await run(linear);
87
+ } finally {
88
+ cache.drop(`${fingerprint}:issues:`);
89
+ cache.drop(`${fingerprint}:issue:`);
90
+ }
91
+ }
92
+
93
+ server.handle(authStatusRpc, async ({ projectId }) => {
94
+ const credential = await credentials.resolve(projectId);
95
+ const projectKeys = await credentials.projectKeys();
96
+ return {
97
+ configured: credential !== null,
98
+ source: credential?.source ?? null,
99
+ keyHint: credential ? keyHint(credential.apiKey) : null,
100
+ projectKeys: projectKeys.map((entry) => ({
101
+ projectId: entry.projectId,
102
+ keyHint: keyHint(entry.apiKey),
103
+ })),
104
+ };
105
+ });
106
+
107
+ server.handle(authSaveRpc, async ({ apiKey: candidate, projectId }) => {
108
+ // Validate the key against Linear before persisting it.
109
+ const linear = createLinearService(createLinearGraphql({ apiKey: candidate, endpoint }));
110
+ const catalog = await linear.catalog();
111
+ await credentials.save(candidate, projectId);
112
+ cache.clear();
113
+ return { viewerName: catalog.viewer.displayName, organizationName: catalog.organization.name };
114
+ });
115
+
116
+ server.handle(authClearRpc, async ({ projectId }) => {
117
+ await credentials.clear(projectId);
118
+ cache.clear();
119
+ return { configured: (await credentials.resolve(projectId)) !== null };
120
+ });
121
+
122
+ server.handle(catalogRpc, async ({ projectId }) =>
123
+ fetchAndStore(projectId, "catalog", null, (linear) => linear.catalog()),
124
+ );
125
+ server.handle(listIssuesRpc, async ({ projectId, ...query }) =>
126
+ fetchAndStore(projectId, "issues", listKey(query), (linear) => linear.listIssues(query)),
127
+ );
128
+ server.handle(getIssueRpc, async ({ id, projectId }) =>
129
+ fetchAndStore(projectId, "issue", id, async (linear) => ({ issue: await linear.getIssue(id) })),
130
+ );
131
+
132
+ server.handle(cachedCatalogRpc, async ({ projectId }) => readCache(projectId, "catalog", null));
133
+ server.handle(cachedIssuesRpc, async ({ projectId, ...query }) =>
134
+ readCache(projectId, "issues", listKey(query)),
135
+ );
136
+ server.handle(cachedIssueRpc, async ({ id, projectId }) => readCache(projectId, "issue", id));
137
+
138
+ server.handle(updateIssueRpc, async ({ id, patch, projectId }) => ({
139
+ issue: await mutate(projectId, (linear) => linear.updateIssue(id, patch)),
140
+ }));
141
+ server.handle(createIssueRpc, async ({ projectId, linearProjectId, ...input }) => ({
142
+ issue: await mutate(projectId, (linear) =>
143
+ linear.createIssue(
144
+ linearProjectId === undefined ? input : { ...input, projectId: linearProjectId },
145
+ ),
146
+ ),
147
+ }));
148
+ server.handle(createCommentRpc, async ({ issueId, body, projectId }) => ({
149
+ comment: await mutate(projectId, (linear) => linear.createComment(issueId, body)),
150
+ }));
151
+
152
+ server.handle(searchIssuesRpc, async ({ query }) =>
153
+ createLinearIssueSearch({ apiKey: await apiKey(), endpoint }).search(query),
154
+ );
155
+
156
+ // Only the plugin's daemon session may append timeline rows, so the client asks the server.
157
+ server.handle(attachIssueCardRpc, async ({ agentId, card }, { paseo }) => {
158
+ try {
159
+ await paseo.agents.ref(agentId).timeline.append({
160
+ type: "plugin",
161
+ id: `linear-issue-${card.identifier}`,
162
+ kind: ISSUE_CARD_KIND,
163
+ version: 1,
164
+ data: card,
165
+ });
166
+ return { attached: true };
167
+ } catch (error) {
168
+ console.error("Linear issue card was not attached", error);
169
+ return { attached: false };
170
+ }
171
+ });
172
+ }