@yannelli/paseo-linear-plugin 1.0.1 → 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 (45) hide show
  1. package/OVERVIEW.md +19 -1
  2. package/README.md +73 -3
  3. package/client/access.tsx +66 -0
  4. package/client/browser.tsx +12 -9
  5. package/client/graph-labels.ts +137 -0
  6. package/client/launch-choices.ts +15 -4
  7. package/client/launch-plan.ts +36 -14
  8. package/client/launch.tsx +9 -4
  9. package/client/live-agents.tsx +407 -0
  10. package/client/live-cursor.tsx +291 -0
  11. package/client/live-feed.tsx +151 -0
  12. package/client/live-graph.tsx +434 -0
  13. package/client/live-header.tsx +312 -0
  14. package/client/live-issues.tsx +345 -0
  15. package/client/live-map.tsx +345 -0
  16. package/client/live-panel.tsx +444 -0
  17. package/client/live-timeline.ts +341 -0
  18. package/client/pill.tsx +393 -0
  19. package/client/project-init.tsx +168 -0
  20. package/client/settings-access.tsx +56 -0
  21. package/client/settings-live.tsx +121 -0
  22. package/client/settings-projects.tsx +9 -0
  23. package/index.client.tsx +39 -1
  24. package/index.server.ts +24 -4
  25. package/package.json +1 -1
  26. package/server/agent-runs.ts +299 -0
  27. package/server/handlers.ts +12 -1
  28. package/server/init.ts +64 -0
  29. package/server/links.ts +153 -0
  30. package/server/live.ts +334 -0
  31. package/server/stores.ts +119 -0
  32. package/server/sync.ts +127 -0
  33. package/shared/activity.ts +308 -0
  34. package/shared/graph-geometry.ts +122 -0
  35. package/shared/graph-model.ts +319 -0
  36. package/shared/links.ts +252 -0
  37. package/shared/live.ts +200 -0
  38. package/shared/map-model.ts +192 -0
  39. package/shared/project-setup.ts +55 -0
  40. package/shared/prompts.ts +16 -1
  41. package/shared/settings.ts +57 -1
  42. package/shared/shell-activity.ts +309 -0
  43. package/shared/shell-lexer.ts +180 -0
  44. package/shared/subagents.ts +106 -0
  45. package/shared/todo-sync.ts +158 -0
@@ -0,0 +1,121 @@
1
+ import { type PluginSurfaceProps, useSettings } from "@getpaseo/plugin/client";
2
+ import {
3
+ SettingsCard,
4
+ SettingsSection,
5
+ SettingsSelect,
6
+ SettingsSwitch,
7
+ } from "@getpaseo/plugin/client/ui";
8
+ import { useCallback, useMemo } from "react";
9
+ import { View } from "react-native";
10
+ import {
11
+ type LinearSettings,
12
+ type LiveView,
13
+ linearSettings,
14
+ type MappingMode,
15
+ } from "../shared/settings";
16
+ import { useProviders } from "./queries";
17
+ import { type ReadySettings, SaveBar, SettingsGate, useSettingsDraft } from "./settings-fields";
18
+ import type { Theme } from "./ui";
19
+
20
+ const MAPPING_OPTIONS: readonly { label: string; value: MappingMode }[] = [
21
+ { label: "Ticket text: free, rough", value: "semantic" },
22
+ { label: "Explore agent: reads the repo first", value: "explore" },
23
+ ];
24
+ const VIEW_OPTIONS: readonly { label: string; value: LiveView }[] = [
25
+ { label: "Map: files grouped by folder", value: "map" },
26
+ { label: "Graph: issue, files, and agents as connected nodes", value: "graph" },
27
+ ];
28
+ const SAME_AGENT = "";
29
+
30
+ export function LiveSettings({ theme }: PluginSurfaceProps) {
31
+ const settings = useSettings(linearSettings);
32
+ const render = useCallback(
33
+ (ready: ReadySettings) => <LiveEditor theme={theme} settings={ready} />,
34
+ [theme],
35
+ );
36
+ return (
37
+ <SettingsGate theme={theme} title="Live" settings={settings}>
38
+ {render}
39
+ </SettingsGate>
40
+ );
41
+ }
42
+
43
+ type Live = LinearSettings["live"];
44
+
45
+ function LiveEditor({ theme, settings }: { theme: Theme; settings: ReadySettings }) {
46
+ const { values, dirty, edit, save, discard } = useSettingsDraft(settings);
47
+ const providers = useProviders();
48
+ const live = values.live;
49
+ const change = useCallback(
50
+ (patch: Partial<Live>) => edit((current) => ({ ...current, live: { ...current.live, ...patch } })),
51
+ [edit],
52
+ );
53
+ const agentOptions = useMemo(() => {
54
+ const options = [{ label: "Same agent as the implementer", value: SAME_AGENT }];
55
+ for (const agent of providers.data ?? []) {
56
+ for (const model of agent.models) {
57
+ options.push({ label: `${agent.label} · ${model.label}`, value: `${agent.id}/${model.id}` });
58
+ }
59
+ }
60
+ if (live.exploreProvider && !options.some((option) => option.value === live.exploreProvider)) {
61
+ options.push({ label: `${live.exploreProvider} · not on this host`, value: live.exploreProvider });
62
+ }
63
+ return options;
64
+ }, [providers.data, live.exploreProvider]);
65
+
66
+ return (
67
+ <View>
68
+ <SettingsSection title="Linear Live">
69
+ <SettingsCard>
70
+ <SettingsSwitch
71
+ label="Show Linear Live"
72
+ hint="Track agents started from an issue: sub-issues, todos, and the files they touch."
73
+ value={live.enabled}
74
+ onValueChange={(enabled) => change({ enabled })}
75
+ />
76
+ <SettingsSelect
77
+ label="Map files with"
78
+ hint="Ticket text finds paths named in the issue. The explore agent reads the repository first, and the implement agent starts when the map is ready. It costs provider usage."
79
+ value={live.mapping}
80
+ options={MAPPING_OPTIONS}
81
+ onValueChange={(mapping) => change({ mapping })}
82
+ disabled={!live.enabled}
83
+ />
84
+ <SettingsSelect
85
+ label="Show files as"
86
+ hint="The view Linear Live opens with. Switch it in the panel at any time."
87
+ value={live.view}
88
+ options={VIEW_OPTIONS}
89
+ onValueChange={(view) => change({ view })}
90
+ disabled={!live.enabled}
91
+ />
92
+ <SettingsSelect
93
+ label="Explore and setup agent"
94
+ hint="It may only read and search the project folder. A faster, cheaper model is usually enough."
95
+ value={live.exploreProvider}
96
+ options={agentOptions}
97
+ onValueChange={(exploreProvider) => change({ exploreProvider })}
98
+ />
99
+ </SettingsCard>
100
+ </SettingsSection>
101
+ <SettingsSection title="Linear status sync">
102
+ <SettingsCard>
103
+ <SettingsSwitch
104
+ label="Update Linear from agent todos"
105
+ hint="After each turn, a todo that starts with a sub-issue key moves that sub-issue forward: In Progress, then Done. The parent moves to In Review when every sub-issue is Done. Statuses never move back."
106
+ value={live.syncTodos}
107
+ onValueChange={(syncTodos) => change({ syncTodos })}
108
+ />
109
+ </SettingsCard>
110
+ </SettingsSection>
111
+ <SaveBar
112
+ theme={theme}
113
+ dirty={dirty}
114
+ saving={settings.saving}
115
+ error={settings.saveError}
116
+ onSave={() => void save()}
117
+ onDiscard={discard}
118
+ />
119
+ </View>
120
+ );
121
+ }
@@ -21,6 +21,8 @@ import {
21
21
  upsertProject,
22
22
  } from "../shared/settings";
23
23
  import { effectiveKeyScope, KeyScopeProvider } from "./key-scope";
24
+ import { ProjectSetup } from "./project-init";
25
+ import { AccessSection } from "./settings-access";
24
26
  import { type PaseoProjectOption, useAuthStatus, useCatalog, useProjects } from "./queries";
25
27
  import {
26
28
  MultilineField,
@@ -120,6 +122,12 @@ function ProjectEditor({ theme, settings }: { theme: Theme; settings: ReadySetti
120
122
  }
121
123
  return (
122
124
  <View>
125
+ <AccessSection
126
+ access={values.access}
127
+ saved={settings.values.access}
128
+ projects={projects.data}
129
+ edit={edit}
130
+ />
123
131
  <SettingsSection title="Project">
124
132
  <SettingsCard>
125
133
  <SettingsSelect
@@ -132,6 +140,7 @@ function ProjectEditor({ theme, settings }: { theme: Theme; settings: ReadySetti
132
140
  </SettingsCard>
133
141
  </SettingsSection>
134
142
  <TeamsSection config={config} change={change} />
143
+ <ProjectSetup theme={theme} config={config} change={change} />
135
144
  <InstructionsSection theme={theme} config={config} change={change} />
136
145
  {AGENT_ACTIONS.map((action) => (
137
146
  <ActionSection key={action} theme={theme} action={action} config={config} change={change} />
package/index.client.tsx CHANGED
@@ -1,7 +1,11 @@
1
1
  import type { PluginClientContext } from "@getpaseo/plugin/client";
2
2
  import { LinearPanel, LinearScreen } from "./client/browser";
3
3
  import { openPluginScreen, registerScreen } from "./client/compat";
4
+ import { gateAgentPanel } from "./client/access";
5
+ import { LivePanel } from "./client/live-panel";
6
+ import { contributeComposerPills } from "./client/pill";
4
7
  import { ConnectionSettings } from "./client/settings-connection";
8
+ import { LiveSettings } from "./client/settings-live";
5
9
  import { ProjectSettings } from "./client/settings-projects";
6
10
  import { PromptSettings } from "./client/settings-prompts";
7
11
  import { focusIssue, panelScope, SCREEN_SCOPE, updateBrowser } from "./client/store";
@@ -11,6 +15,7 @@ import { ISSUE_CARD_KIND, IssueCardSchema, LINEAR_IDENTIFIER } from "./shared/li
11
15
 
12
16
  const SCREEN_ID = "linear";
13
17
  const PANEL_ID = "issues";
18
+ const LIVE_PANEL_ID = "live";
14
19
 
15
20
  export default function contribute(client: PluginClientContext) {
16
21
  client.addAttachmentSource(issueAttachments);
@@ -40,6 +45,19 @@ export default function contribute(client: PluginClientContext) {
40
45
  icon: "MessageSquare",
41
46
  Component: PromptSettings,
42
47
  });
48
+ client.addWorkspacePanel({
49
+ id: LIVE_PANEL_ID,
50
+ title: "Linear Live",
51
+ icon: "Radar",
52
+ context: "agent",
53
+ Component: gateAgentPanel(LivePanel),
54
+ });
55
+ client.addSettingsScreen({
56
+ id: "live",
57
+ title: "Live",
58
+ icon: "Radar",
59
+ Component: LiveSettings,
60
+ });
43
61
  client.addSettingsScreen({
44
62
  id: "projects",
45
63
  title: "Projects",
@@ -77,6 +95,26 @@ export default function contribute(client: PluginClientContext) {
77
95
  openPanel(PANEL_ID);
78
96
  },
79
97
  });
98
+ client.addCommandCenterItem({
99
+ id: "open-live",
100
+ title: "Linear: Open Linear Live",
101
+ icon: "Radar",
102
+ keywords: ["linear", "live", "progress", "map"],
103
+ context: "agent",
104
+ onSelect({ openPanel }) {
105
+ openPanel(LIVE_PANEL_ID);
106
+ },
107
+ });
108
+ client.addCommandCenterItem({
109
+ id: "setup-project",
110
+ title: "Linear: Set up project prompts",
111
+ icon: "Sparkles",
112
+ keywords: ["linear", "init", "setup", "agents.md", "guidelines"],
113
+ context: "global",
114
+ onSelect({ openSettings }) {
115
+ openSettings("projects");
116
+ },
117
+ });
80
118
  client.addCommandCenterItem({
81
119
  id: "settings",
82
120
  title: "Linear: Settings",
@@ -107,5 +145,5 @@ export default function contribute(client: PluginClientContext) {
107
145
  schema: IssueCardSchema,
108
146
  Component: IssueCardRow,
109
147
  });
110
- return () => {};
148
+ return contributeComposerPills(client);
111
149
  }
package/index.server.ts CHANGED
@@ -1,12 +1,32 @@
1
+ import path from "node:path";
1
2
  import type { PluginServerContext } from "@getpaseo/plugin/server";
2
3
  import { createCredentialStore, defaultCredentialPath } from "./server/credentials";
3
4
  import { registerHandlers } from "./server/handlers";
4
- import { linearSettings } from "./shared/settings";
5
+ import { registerInit } from "./server/init";
6
+ import { registerLive } from "./server/live";
7
+ import { createLaunchQueue, createMapStore } from "./server/stores";
8
+ import { registerSync } from "./server/sync";
9
+ import { accessRpc, type LinearSettings, linearSettings } from "./shared/settings";
5
10
 
6
11
  export default function contribute(server: PluginServerContext) {
7
- server.registerSettings(linearSettings);
8
- registerHandlers(server, {
9
- credentials: createCredentialStore({ file: defaultCredentialPath() }),
12
+ const settings = server.registerSettings(linearSettings);
13
+ const readSettings = async (): Promise<LinearSettings | null> => {
14
+ const state = await settings.read();
15
+ return state.status === "ready" ? state.values : null;
16
+ };
17
+ const credentialPath = defaultCredentialPath();
18
+ const access = registerHandlers(server, {
19
+ credentials: createCredentialStore({ file: credentialPath }),
20
+ });
21
+ const dataDirectory = path.dirname(credentialPath);
22
+ const maps = createMapStore(path.join(dataDirectory, "live-maps.json"));
23
+ const launches = createLaunchQueue(path.join(dataDirectory, "pending-launches.json"));
24
+ registerLive(server, { access, maps, launches, readSettings });
25
+ registerSync(server, { access, readSettings });
26
+ registerInit(server, { readSettings });
27
+ server.handle(accessRpc, async () => {
28
+ const values = await readSettings();
29
+ return values?.access ?? linearSettings.schema.parse({}).access;
10
30
  });
11
31
  return () => {};
12
32
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yannelli/paseo-linear-plugin",
3
- "version": "1.0.1",
3
+ "version": "1.1.0-beta.0",
4
4
  "description": "Browse and triage Linear issues in Paseo and start agents from them",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -0,0 +1,299 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { PluginHandlerContext, PluginLifecycleEvents } from "@getpaseo/plugin/server";
3
+ import { type ZodType, z } from "zod";
4
+ import { relativePath } from "../shared/activity";
5
+ import type { Job } from "../shared/live";
6
+ import { lastJsonObject } from "../shared/live";
7
+
8
+ // Explore and project setup run a read-only agent, wait for it in the background, and read a
9
+ // JSON answer from its last message. Claude ignores outputSchema, so the prompt asks for JSON.
10
+
11
+ export type Paseo = PluginHandlerContext["paseo"];
12
+ type WorkspaceHandle = Awaited<ReturnType<Paseo["workspaces"]["open"]>>;
13
+ type AgentHandle = ReturnType<Paseo["agents"]["ref"]>;
14
+ export type PermissionRequest = PluginLifecycleEvents["agent.permission_requested"]["request"];
15
+ export type PermissionAnswer = Parameters<AgentHandle["respondToPermission"]>[0]["response"];
16
+
17
+ export interface InternalRun<T> {
18
+ paseo: Paseo;
19
+ workspace: WorkspaceHandle;
20
+ /** Folder the agent works in; its reads must stay inside. */
21
+ cwd: string;
22
+ provider: string;
23
+ title: string;
24
+ prompt: string;
25
+ labels: Record<string, string>;
26
+ schema: ZodType<T>;
27
+ timeoutMs: number;
28
+ }
29
+
30
+ interface SnapshotEntry {
31
+ provider: string;
32
+ status: string;
33
+ enabled?: boolean;
34
+ modes?: readonly { id: string; label?: string }[];
35
+ }
36
+
37
+ const SAFE_MODES = [/read.?only/i, /^default$/i, /ask/i];
38
+ const UNSAFE_MODE = /bypass|full|yolo|danger/i;
39
+
40
+ // Never inherit a provider default that skips approvals. Claude's "default" asks before
41
+ // edits, so an edit attempt stops the run. Codex has no read-only mode; "auto" asks on request.
42
+ export function safeModeId(modes: SnapshotEntry["modes"]): string | undefined {
43
+ for (const pattern of SAFE_MODES) {
44
+ const match = modes?.find((mode) => pattern.test(mode.id));
45
+ if (match) return match.id;
46
+ }
47
+ return modes?.find((mode) => !UNSAFE_MODE.test(`${mode.id} ${mode.label ?? ""}`))?.id;
48
+ }
49
+
50
+ const UNSUPPORTED_KEYWORDS = new Set([
51
+ "$schema",
52
+ "default",
53
+ "minLength",
54
+ "maxLength",
55
+ "minItems",
56
+ "maxItems",
57
+ ]);
58
+
59
+ /** JSON schema for strict structured output: every property required, no extras. */
60
+ export function strictJsonSchema(schema: ZodType): Record<string, unknown> {
61
+ const strip = (node: unknown): unknown => {
62
+ if (Array.isArray(node)) return node.map(strip);
63
+ if (!node || typeof node !== "object") return node;
64
+ return Object.fromEntries(
65
+ Object.entries(node)
66
+ .filter(([key]) => !UNSUPPORTED_KEYWORDS.has(key))
67
+ .map(([key, value]) => [key, strip(value)]),
68
+ );
69
+ };
70
+ return strip(z.toJSONSchema(schema, { io: "output" })) as Record<string, unknown>;
71
+ }
72
+
73
+ // Some Claude builds search only through the shell, so internal agents may run a short list of
74
+ // commands that read. Anything that writes, runs other programs, or leaves the folder is denied.
75
+ const READ_COMMAND = /^(?:ls|find|rg|grep|cat|head|tail|wc|pwd|git (?:ls-files|grep))(?:\s|$)/;
76
+ const UNSAFE_SHELL =
77
+ /[`;&<>~$\n\r]|\.\.|\s-(?:exec|execdir|ok|okdir|delete|fprint\w*|fls|O)\b|\s--(?:pre|open-files-in-pager)\b/;
78
+ /** Quoted text the shell does not expand: single quotes, or double quotes without $ or `. */
79
+ const LITERAL = /'[^']*'|"[^"$`\\]*"/g;
80
+
81
+ function insideFolder(filePath: string, cwd: string): boolean {
82
+ return filePath.replace(/\/+$/, "") === cwd.replace(/\/+$/, "") || relativePath(filePath, cwd) !== null;
83
+ }
84
+
85
+ export function readOnlyCommand(command: string, cwd: string): boolean {
86
+ const text = command.replace(/\s2>\s*\/dev\/null/g, " ").trim();
87
+ const bare = text.replace(LITERAL, "Q");
88
+ if (!bare || UNSAFE_SHELL.test(bare)) return false;
89
+ const outside = text.split(/\s+|=/).some((word) => {
90
+ const filePath = word.replace(/['"]/g, "");
91
+ return filePath.startsWith("/") && !insideFolder(filePath, cwd);
92
+ });
93
+ return !outside && bare.split("|").every((part) => READ_COMMAND.test(part.trim()));
94
+ }
95
+
96
+ /** Prompt lines that match what readOnlyAnswer allows. */
97
+ export const READ_ONLY_RULES = [
98
+ "Read and search only. Do not create, edit, or delete files.",
99
+ "To find files, use your read and search tools, or these shell commands, one at a time and",
100
+ "without redirection: ls, find, rg, grep, cat, head, tail, wc, git ls-files, git grep.",
101
+ "Keep paths inside this folder. Other commands are denied.",
102
+ "Work alone: do not start subagents or background tasks.",
103
+ ];
104
+
105
+ const DENIED =
106
+ "Not allowed in this read-only run. Read and search files in this repository only, one " +
107
+ "command at a time, without redirection.";
108
+
109
+ /** No one watches internal agents, so the plugin answers their prompts: reads yes, else no. */
110
+ export function readOnlyAnswer(request: PermissionRequest, cwd: string): PermissionAnswer {
111
+ if (request.kind === "question") {
112
+ return { behavior: "deny", message: "No one can answer here. Decide yourself and continue." };
113
+ }
114
+ const detail = request.detail;
115
+ const command =
116
+ detail?.type === "shell" ? detail.command : (request.input as { command?: unknown })?.command;
117
+ const allowed =
118
+ request.kind === "tool" &&
119
+ ((detail?.type === "read" && insideFolder(detail.filePath, cwd)) ||
120
+ (detail?.type === "search" && detail.toolName !== "web_search") ||
121
+ (typeof command === "string" && readOnlyCommand(command, cwd)));
122
+ return allowed ? { behavior: "allow" } : { behavior: "deny", message: DENIED };
123
+ }
124
+
125
+ function entriesOf(snapshot: unknown): readonly SnapshotEntry[] {
126
+ const value = snapshot as {
127
+ entries?: readonly SnapshotEntry[];
128
+ compactSnapshot?: { entries?: readonly SnapshotEntry[] } | null;
129
+ };
130
+ const entries = value.entries ?? [];
131
+ return entries.length > 0 ? entries : (value.compactSnapshot?.entries ?? []);
132
+ }
133
+
134
+ /** Uses `preferred` (`provider` or `provider/model`) when ready, else the first ready provider. */
135
+ export async function agentConfigFor(paseo: Paseo, preferred: string) {
136
+ const entries = entriesOf(await paseo.providers.snapshot()).filter(
137
+ (entry) => entry.status === "ready" && entry.enabled !== false,
138
+ );
139
+ const wanted = preferred.split("/")[0] ?? "";
140
+ const entry = entries.find((candidate) => candidate.provider === wanted) ?? entries[0];
141
+ if (!entry) throw new Error("No agent provider is ready on this host");
142
+ const provider = entry.provider === wanted && preferred.includes("/") ? preferred : entry.provider;
143
+ const modeId = safeModeId(entry.modes);
144
+ return { provider, ...(modeId ? { modeId } : {}) };
145
+ }
146
+
147
+ async function lastReply(handle: AgentHandle) {
148
+ const page = await handle.timeline.refetch({
149
+ direction: "tail",
150
+ limit: 60,
151
+ projection: "projected",
152
+ });
153
+ for (const entry of [...page.entries].reverse()) {
154
+ const item = entry.item as { type: string; text?: unknown };
155
+ if (item.type === "assistant_message" && typeof item.text === "string") return item.text;
156
+ }
157
+ return "";
158
+ }
159
+
160
+ const MAX_STALLS = 20;
161
+ const STALL_DELAY_MS = 500;
162
+
163
+ // waitForFinish also returns when the agent asks for permission, so answer and keep waiting.
164
+ // A prompt is answered once; while the daemon applies an answer, the loop waits briefly.
165
+ export async function waitUntilIdle(handle: AgentHandle, cwd: string, timeoutMs: number) {
166
+ const deadline = Date.now() + timeoutMs;
167
+ const answered = new Set<string>();
168
+ let stalls = 0;
169
+ for (;;) {
170
+ const remaining = deadline - Date.now();
171
+ if (remaining <= 0) throw new Error("The agent did not finish in time");
172
+ const finish = await handle.waitForFinish(remaining);
173
+ if (finish.status === "idle") return;
174
+ if (finish.status !== "permission") {
175
+ throw new Error(finish.error ?? `The agent stopped with status ${finish.status}`);
176
+ }
177
+ const unanswered = (requests: readonly PermissionRequest[] | null | undefined) =>
178
+ (requests ?? []).filter((request) => !answered.has(request.id));
179
+ let pending = unanswered(finish.final?.pendingPermissions);
180
+ if (pending.length === 0) {
181
+ await handle.refresh();
182
+ pending = unanswered(handle.pendingPermissions);
183
+ }
184
+ if (pending.length === 0) {
185
+ stalls += 1;
186
+ if (answered.size === 0 || stalls > MAX_STALLS) {
187
+ throw new Error("The agent is waiting for input");
188
+ }
189
+ await new Promise((resolve) => setTimeout(resolve, STALL_DELAY_MS));
190
+ continue;
191
+ }
192
+ stalls = 0;
193
+ for (const request of pending) {
194
+ answered.add(request.id);
195
+ await handle
196
+ .respondToPermission({ requestId: request.id, response: readOnlyAnswer(request, cwd) })
197
+ .catch((error: unknown) => console.error("Linear could not answer a permission", error));
198
+ }
199
+ }
200
+ }
201
+
202
+ /** Waits for an internal agent, reads its JSON answer, and archives it either way. */
203
+ export async function finishInternalRun<T>(
204
+ handle: AgentHandle,
205
+ cwd: string,
206
+ schema: ZodType<T>,
207
+ timeoutMs: number,
208
+ ): Promise<T> {
209
+ try {
210
+ await waitUntilIdle(handle, cwd, timeoutMs);
211
+ const parsed = schema.safeParse(lastJsonObject(await lastReply(handle)));
212
+ if (!parsed.success) throw new Error("The agent did not return the expected JSON");
213
+ return parsed.data;
214
+ } finally {
215
+ await handle.archive().catch(() => undefined);
216
+ }
217
+ }
218
+
219
+ export interface StartedRun<T> {
220
+ agentId: string;
221
+ result: Promise<T>;
222
+ }
223
+
224
+ export async function startInternalRun<T>(run: InternalRun<T>): Promise<StartedRun<T>> {
225
+ const config = await agentConfigFor(run.paseo, run.provider);
226
+ const handle = await run.workspace.agents.create({
227
+ config,
228
+ title: run.title,
229
+ prompt: run.prompt,
230
+ labels: run.labels,
231
+ outputSchema: strictJsonSchema(run.schema),
232
+ });
233
+ const result = finishInternalRun(handle, run.cwd, run.schema, run.timeoutMs);
234
+ return { agentId: handle.id, result };
235
+ }
236
+
237
+ export interface JobStore<T> {
238
+ get(id: string): { job: Job; value: T | null } | null;
239
+ start(id: string | null, begin: () => Promise<StartedRun<T>>): Promise<Job>;
240
+ /** Resolves with the job once it is done or failed; null for an unknown id. */
241
+ wait(id: string): Promise<Job | null>;
242
+ }
243
+
244
+ interface JobRecord<T> {
245
+ job: Job;
246
+ value: T | null;
247
+ settled: Promise<void>;
248
+ }
249
+
250
+ export function createJobStore<T>(): JobStore<T> {
251
+ const jobs = new Map<string, JobRecord<T>>();
252
+ return {
253
+ get: (id) => {
254
+ const record = jobs.get(id);
255
+ return record ? { job: record.job, value: record.value } : null;
256
+ },
257
+ async wait(id) {
258
+ const record = jobs.get(id);
259
+ if (!record) return null;
260
+ await record.settled;
261
+ return record.job;
262
+ },
263
+ async start(id, begin) {
264
+ const job: Job = {
265
+ id: id ?? randomUUID(),
266
+ status: "running",
267
+ agentId: null,
268
+ error: null,
269
+ startedAt: new Date().toISOString(),
270
+ };
271
+ let settle = () => {};
272
+ const settled = new Promise<void>((resolve) => {
273
+ settle = resolve;
274
+ });
275
+ const record: JobRecord<T> = { job, value: null, settled };
276
+ jobs.set(job.id, record);
277
+ const fail = (error: unknown) => {
278
+ record.job = {
279
+ ...record.job,
280
+ status: "failed",
281
+ error: error instanceof Error ? error.message : String(error),
282
+ };
283
+ settle();
284
+ };
285
+ try {
286
+ const started = await begin();
287
+ record.job = { ...record.job, agentId: started.agentId };
288
+ started.result.then((value) => {
289
+ record.value = value;
290
+ record.job = { ...record.job, status: "done" };
291
+ settle();
292
+ }, fail);
293
+ } catch (error) {
294
+ fail(error);
295
+ }
296
+ return record.job;
297
+ },
298
+ };
299
+ }
@@ -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,64 @@
1
+ import type { PluginServerContext } from "@getpaseo/plugin/server";
2
+ import { type InitProposal, InitProposalSchema, initStartRpc, initStatusRpc } from "../shared/live";
3
+ import { AGENT_LABELS } from "../shared/prompts";
4
+ import type { LinearSettings } from "../shared/settings";
5
+ import { createJobStore, READ_ONLY_RULES, startInternalRun } from "./agent-runs";
6
+
7
+ export function initPrompt(rootPath: string): string {
8
+ return [
9
+ `Set up Linear agent prompts for the repository at ${rootPath}.`,
10
+ "",
11
+ ...READ_ONLY_RULES,
12
+ "",
13
+ "Read the guidance the repository gives agents and contributors: AGENTS.md, CLAUDE.md and",
14
+ "similar files, CONTRIBUTING, the README, package scripts or build files, CI workflows, pull",
15
+ "request templates, commit conventions, and the test setup.",
16
+ "",
17
+ "Then write:",
18
+ "- instructions: up to 12 short lines an agent must follow here: commands, conventions, limits.",
19
+ '- steps: 3 to 8 short checks an agent runs before it finishes, such as "Run npm run check".',
20
+ "- implement: extra guidance for implementing an issue in this repository, or an empty string.",
21
+ "- review: extra guidance for reviewing a change in this repository, or an empty string.",
22
+ "Use only facts you found in the repository. Do not repeat generic advice.",
23
+ "",
24
+ "Reply with only a JSON object in a ```json block:",
25
+ '{"instructions":"...","steps":["..."],"implement":"...","review":"..."}',
26
+ ].join("\n");
27
+ }
28
+
29
+ export interface InitDependencies {
30
+ readSettings(): Promise<LinearSettings | null>;
31
+ }
32
+
33
+ export function registerInit(server: PluginServerContext, dependencies: InitDependencies) {
34
+ const jobs = createJobStore<InitProposal>();
35
+
36
+ server.handle(initStartRpc, async ({ projectId, rootPath }, { paseo }) => {
37
+ // Only known Paseo projects can be set up, so the client cannot point an agent anywhere.
38
+ const { projects } = await paseo.projects.list();
39
+ const project = projects.find((entry) => entry.projectId === projectId);
40
+ if (!project || project.projectRootPath !== rootPath) {
41
+ throw new Error("That project is not on this host");
42
+ }
43
+ const settings = await dependencies.readSettings();
44
+ const job = await jobs.start(null, async () =>
45
+ startInternalRun({
46
+ paseo,
47
+ workspace: await paseo.workspaces.open(rootPath),
48
+ cwd: rootPath,
49
+ provider: settings?.live.exploreProvider || settings?.launch.provider || "",
50
+ title: `Set up Linear prompts for ${project.projectDisplayName}`,
51
+ prompt: initPrompt(rootPath),
52
+ labels: { [AGENT_LABELS.init]: projectId },
53
+ schema: InitProposalSchema,
54
+ timeoutMs: 20 * 60_000,
55
+ }),
56
+ );
57
+ return { job };
58
+ });
59
+
60
+ server.handle(initStatusRpc, async ({ jobId }) => {
61
+ const record = jobs.get(jobId);
62
+ return { job: record?.job ?? null, proposal: record?.value ?? null };
63
+ });
64
+ }