@yannelli/paseo-linear-plugin 0.4.0 → 1.0.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/README.md CHANGED
@@ -31,9 +31,11 @@ The images are illustrations with sample data. They come from `docs/graphics`; s
31
31
 
32
32
  ```sh
33
33
  paseo plugin add npm:@yannelli/paseo-linear-plugin
34
- paseo plugin ls linear
34
+ paseo plugin ls paseo-linear-plugin
35
35
  ```
36
36
 
37
+ Version 0.4.0 and earlier used the id `linear`. To upgrade from those versions, run `paseo plugin remove linear` first, then add the plugin again. Your saved keys stay in place.
38
+
37
39
  To pin a version, add it to the source, for example `npm:@yannelli/paseo-linear-plugin@0.1.0`. To install a Git tag instead, run `paseo plugin add github:yannelli/paseo-linear-plugin --ref v0.1.0`. Plugins run trusted code with the daemon user's access.
38
40
 
39
41
  ## Connect Linear
@@ -101,16 +103,17 @@ Select **Start agent** or **Start review** in the issue view. The agent setup pa
101
103
  1. Edit the prompt in the prompt box if you want. The prompt follows the template and options until you edit it. Select **Reset prompt** to go back to the template.
102
104
  2. Use the controls under the prompt box, as in the Paseo composer. They set the prompt template (Implement or Review), the model, the thinking level, and the mode. The thinking level shows when the model has levels, and the mode shows when the agent has modes.
103
105
  - The model picker lists the providers first. Select a provider to see its models, or search to find models from all providers.
104
- - Choosing a model from another provider also changes the agent, and resets the thinking level and mode to that agent's defaults.
106
+ - Choosing a model from another provider also changes the agent. The thinking level and mode change to the choices you last used with that agent, or to its defaults.
107
+ - The controls start from your last choices: the model, thinking level, and mode of your last launch from the plugin, then the choices Paseo remembers for new agents. A default model in the settings takes priority for the model. On the mobile app, the plugin cannot read Paseo's choices, so it remembers only launches from the current session.
105
108
  3. Choose the **Project**. The default is the project of the current workspace. Without a workspace, it is the last project you used for the team, then a project mapped to the team.
106
109
  4. Choose where the agent runs in **Run in**:
107
110
  - **New worktree** on the issue branch.
108
111
  - **Check out PR #N** for a review, when the issue links a GitHub pull request.
109
- - **Check out issue branch** for a review.
112
+ - **Check out issue branch** for a review. If nobody pushed the branch yet, the plugin starts a new worktree on it.
110
113
  - **Existing agent workspace** or **Implementation workspace**, when an agent already works on the issue.
111
114
  - **This workspace**, or **Project folder** when you are not in a workspace of that project.
112
115
 
113
- The worktree and checkout options show only for a Git project.
116
+ The worktree and checkout options show only for a Git project. When agents run in worktrees (the default), a review uses the pull request, then the implementation workspace, then the issue branch.
114
117
  5. Set the options: **Include comments in the prompt**, **Move the issue to In Progress**, and **Assign the issue to me**. The last two show only for Implement, and only when they would change the issue.
115
118
  6. Select **Start agent** or **Start review**.
116
119
 
@@ -174,7 +177,7 @@ npm run check
174
177
  paseo plugin add "$PWD"
175
178
  ```
176
179
 
177
- After you edit the source, run `paseo plugin reload linear`. `npm run check` runs the typecheck and the tests. The tests cover the Linear client, the key file, the cache, the handlers, sub-issue nesting, the Markdown parser and task list toggle, the description autosave, the agent options, and the release scripts. They use a local GraphQL server and do not call Linear.
180
+ After you edit the source, run `paseo plugin reload paseo-linear-plugin`. `npm run check` runs the typecheck and the tests. The tests cover the Linear client, the key file, the cache, the handlers, sub-issue nesting, the Markdown parser and task list toggle, the description autosave, the agent options, and the release scripts. They use a local GraphQL server and do not call Linear.
178
181
 
179
182
  ## Graphics
180
183
 
@@ -1,5 +1,7 @@
1
1
  // Turns the daemon's provider snapshot into Agent, Model, Effort, and Mode choices.
2
2
 
3
+ import type { AgentPreferences } from "./agent-preferences";
4
+
3
5
  export interface Choice {
4
6
  id: string;
5
7
  label: string;
@@ -112,20 +114,26 @@ export interface AgentPicks {
112
114
  mode: string | null;
113
115
  }
114
116
 
115
- /** `configured` is the settings default in `provider/model` form. Empty uses the first agent. */
117
+ /**
118
+ * `configured` is the settings default in `provider/model` form. Empty falls back to the
119
+ * remembered agent, then the first agent. Effort and mode come from `remembered` when unpicked.
120
+ */
116
121
  export function resolveAgent(
117
122
  agents: readonly AgentChoice[],
118
123
  picks: AgentPicks,
119
124
  configured: string,
125
+ remembered: AgentPreferences = {},
120
126
  ): AgentSelection | null {
121
127
  const [configuredAgent, ...rest] = configured.split("/");
122
128
  const configuredModel = rest.join("/");
123
129
  const agent =
124
130
  agents.find((entry) => entry.id === picks.agent) ??
125
131
  agents.find((entry) => entry.id === configuredAgent) ??
132
+ agents.find((entry) => entry.id === remembered.provider) ??
126
133
  agents[0];
127
134
  if (!agent) return null;
128
- const preferredModel = agent.id === configuredAgent ? configuredModel : null;
135
+ const preference = remembered.providerPreferences?.[agent.id];
136
+ const preferredModel = agent.id === configuredAgent ? configuredModel : preference?.model;
129
137
  const model =
130
138
  agent.models.find((entry) => entry.id === picks.model) ??
131
139
  agent.models.find((entry) => entry.id === preferredModel) ??
@@ -134,10 +142,12 @@ export function resolveAgent(
134
142
  if (!model) return null;
135
143
  const effort =
136
144
  model.efforts.find((entry) => entry.id === picks.effort) ??
145
+ model.efforts.find((entry) => entry.id === preference?.thinkingByModel?.[model.id]) ??
137
146
  model.efforts.find((entry) => entry.id === model.defaultEffortId) ??
138
147
  null;
139
148
  const mode =
140
149
  agent.modes.find((entry) => entry.id === picks.mode) ??
150
+ agent.modes.find((entry) => entry.id === preference?.mode) ??
141
151
  agent.modes.find((entry) => entry.id === agent.defaultModeId) ??
142
152
  null;
143
153
  return { agent, model, effort, mode };
@@ -0,0 +1,112 @@
1
+ import { z } from "zod";
2
+
3
+ // Agent choices remembered between launches, in the shape Paseo's new-agent form stores them.
4
+
5
+ const ProviderPreferenceSchema = z.object({
6
+ model: z.string().optional(),
7
+ mode: z.string().optional(),
8
+ thinkingByModel: z.record(z.string(), z.string()).optional(),
9
+ });
10
+
11
+ const AgentPreferencesSchema = z.object({
12
+ provider: z.string().optional(),
13
+ providerPreferences: z.record(z.string(), ProviderPreferenceSchema).optional(),
14
+ });
15
+
16
+ export type ProviderPreference = z.infer<typeof ProviderPreferenceSchema>;
17
+ export type AgentPreferences = z.infer<typeof AgentPreferencesSchema>;
18
+
19
+ /** What the user last picked for one launch. */
20
+ export interface RememberedLaunch {
21
+ agent: string;
22
+ model: string;
23
+ effort: string | null;
24
+ mode: string | null;
25
+ }
26
+
27
+ interface KeyValueStorage {
28
+ getItem(key: string): string | null;
29
+ setItem(key: string, value: string): void;
30
+ }
31
+
32
+ /** Paseo's own new-agent preferences. Read only: the app owns this key. */
33
+ export const PASEO_PREFERENCES_KEY = "@paseo:create-agent-preferences";
34
+ export const PLUGIN_PREFERENCES_KEY = "@yannelli/paseo-linear-plugin:agent-preferences";
35
+
36
+ let sessionPreferences: AgentPreferences = {};
37
+
38
+ // Paseo keeps preferences in AsyncStorage, which is localStorage on web and desktop.
39
+ // Native apps have no localStorage, so only this session's launches are remembered there.
40
+ function storage(): KeyValueStorage | null {
41
+ try {
42
+ const candidate = (globalThis as { localStorage?: KeyValueStorage }).localStorage;
43
+ return candidate && typeof candidate.getItem === "function" ? candidate : null;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ export function parsePreferences(raw: string | null): AgentPreferences {
50
+ if (!raw) return {};
51
+ try {
52
+ const parsed = AgentPreferencesSchema.safeParse(JSON.parse(raw));
53
+ return parsed.success ? parsed.data : {};
54
+ } catch {
55
+ return {};
56
+ }
57
+ }
58
+
59
+ function readStored(key: string): AgentPreferences {
60
+ try {
61
+ return parsePreferences(storage()?.getItem(key) ?? null);
62
+ } catch {
63
+ return {};
64
+ }
65
+ }
66
+
67
+ /** Layers `top` over `base`, per provider and per model. */
68
+ export function mergePreferences(base: AgentPreferences, top: AgentPreferences): AgentPreferences {
69
+ const providers: Record<string, ProviderPreference> = { ...base.providerPreferences };
70
+ for (const [id, preference] of Object.entries(top.providerPreferences ?? {})) {
71
+ const below = providers[id] ?? {};
72
+ providers[id] = {
73
+ ...below,
74
+ ...preference,
75
+ thinkingByModel: { ...below.thinkingByModel, ...preference.thinkingByModel },
76
+ };
77
+ }
78
+ return { provider: top.provider ?? base.provider, providerPreferences: providers };
79
+ }
80
+
81
+ export function withLaunch(
82
+ preferences: AgentPreferences,
83
+ launch: RememberedLaunch,
84
+ ): AgentPreferences {
85
+ return mergePreferences(preferences, {
86
+ provider: launch.agent,
87
+ providerPreferences: {
88
+ [launch.agent]: {
89
+ model: launch.model,
90
+ ...(launch.mode ? { mode: launch.mode } : {}),
91
+ ...(launch.effort ? { thinkingByModel: { [launch.model]: launch.effort } } : {}),
92
+ },
93
+ },
94
+ });
95
+ }
96
+
97
+ /** Paseo's remembered choices, overridden by launches made from this plugin. */
98
+ export function loadAgentPreferences(): AgentPreferences {
99
+ const paseo = readStored(PASEO_PREFERENCES_KEY);
100
+ const plugin = mergePreferences(readStored(PLUGIN_PREFERENCES_KEY), sessionPreferences);
101
+ return mergePreferences(paseo, plugin);
102
+ }
103
+
104
+ export function rememberLaunch(launch: RememberedLaunch): void {
105
+ sessionPreferences = withLaunch(sessionPreferences, launch);
106
+ try {
107
+ const stored = withLaunch(readStored(PLUGIN_PREFERENCES_KEY), launch);
108
+ storage()?.setItem(PLUGIN_PREFERENCES_KEY, JSON.stringify(stored));
109
+ } catch {
110
+ // Storage full or blocked: this session still remembers the launch.
111
+ }
112
+ }
@@ -4,6 +4,7 @@ import { useCallback, useMemo, useState } from "react";
4
4
  import type { IssueDetail } from "../shared/linear";
5
5
  import type { AgentAction, LinearSettings } from "../shared/settings";
6
6
  import { type AgentPicks, resolveAgent } from "./agent-options";
7
+ import { loadAgentPreferences } from "./agent-preferences";
7
8
  import {
8
9
  defaultProjectId,
9
10
  type Placement,
@@ -52,6 +53,7 @@ export function useLaunchChoices(input: {
52
53
  const [projectChoice, setProjectChoice] = useState<string | null>(null);
53
54
  const [placementChoice, setPlacementChoice] = useState<Placement | null>(null);
54
55
  const [picks, setPicks] = useState<AgentPicks>(NO_PICKS);
56
+ const [remembered] = useState(loadAgentPreferences);
55
57
  const projectId =
56
58
  projectChoice ??
57
59
  defaultProjectId({ target, teamId: issue.team.id, settings, projects: projects.data });
@@ -75,7 +77,7 @@ export function useLaunchChoices(input: {
75
77
  settings.launch.isolation,
76
78
  );
77
79
  const agents = providers.data ?? [];
78
- const agent = resolveAgent(agents, picks, settings.launch.provider);
80
+ const agent = resolveAgent(agents, picks, settings.launch.provider, remembered);
79
81
  const chooseProject = useCallback((value: string) => {
80
82
  setProjectChoice(value);
81
83
  setPlacementChoice(null);
@@ -10,6 +10,7 @@ import {
10
10
  import { AGENT_LABELS, agentTitle } from "../shared/prompts";
11
11
  import type { AgentAction, Isolation, LinearSettings } from "../shared/settings";
12
12
  import { type AgentSelection, agentConfig } from "./agent-options";
13
+ import { rememberLaunch } from "./agent-preferences";
13
14
  import type { PaseoProjectOption } from "./queries";
14
15
  import { errorMessage } from "./ui";
15
16
 
@@ -91,10 +92,13 @@ export function placementOptions(input: {
91
92
  return options;
92
93
  }
93
94
 
94
- // Reviews look at existing work: the pull request, then the implementing agent's workspace.
95
+ // Reviews look at existing work: the pull request, the implementing agent's workspace, then
96
+ // the issue branch. Checking out a branch nobody pushed yet starts a new worktree instead.
95
97
  function preferredPlacement(action: AgentAction, isolation: Isolation): Placement[] {
96
- if (action === "review") return ["pull-request", "agent-workspace", "workspace"];
97
- return isolation === "worktree" ? ["worktree", "workspace"] : ["workspace"];
98
+ const review = action === "review";
99
+ const existing: Placement[] = review ? ["pull-request", "agent-workspace"] : [];
100
+ const fresh: Placement[] = isolation === "worktree" ? [review ? "branch" : "worktree"] : [];
101
+ return [...existing, ...fresh, "workspace"];
98
102
  }
99
103
 
100
104
  export function resolvePlacement(
@@ -139,21 +143,32 @@ export interface LaunchRequest {
139
143
  keyScope: string | null;
140
144
  }
141
145
 
142
- type Paseo = ReturnType<typeof usePaseo>;
146
+ export type Paseo = ReturnType<typeof usePaseo>;
143
147
 
144
- async function openWorkspace(paseo: Paseo, request: LaunchRequest) {
148
+ // Linear suggests a branch name for every issue, even when nobody has pushed that branch.
149
+ // The plugin API passes only the daemon's message, so match its "Unknown branch" text.
150
+ function isUnknownBranch(error: unknown): boolean {
151
+ return errorMessage(error).includes("Unknown branch");
152
+ }
153
+
154
+ function createIssueWorktree(paseo: Paseo, request: LaunchRequest, title: string) {
155
+ const { issue, project } = request;
156
+ return paseo.workspaces.create({
157
+ title,
158
+ source: {
159
+ kind: "worktree",
160
+ cwd: project.rootPath,
161
+ projectId: project.projectId,
162
+ action: "branch-off",
163
+ branchName: issue.branchName,
164
+ },
165
+ });
166
+ }
167
+
168
+ export async function openWorkspace(paseo: Paseo, request: LaunchRequest) {
145
169
  const { issue, project } = request;
146
170
  if (request.placement === "worktree") {
147
- return paseo.workspaces.create({
148
- title: `${issue.identifier} ${issue.title}`,
149
- source: {
150
- kind: "worktree",
151
- cwd: project.rootPath,
152
- projectId: project.projectId,
153
- action: "branch-off",
154
- branchName: issue.branchName,
155
- },
156
- });
171
+ return createIssueWorktree(paseo, request, `${issue.identifier} ${issue.title}`);
157
172
  }
158
173
  if (request.placement === "pull-request" && request.prNumber !== null) {
159
174
  return paseo.workspaces.create({
@@ -168,16 +183,22 @@ async function openWorkspace(paseo: Paseo, request: LaunchRequest) {
168
183
  });
169
184
  }
170
185
  if (request.placement === "branch") {
171
- return paseo.workspaces.create({
172
- title: `Review ${issue.identifier}`,
173
- source: {
174
- kind: "worktree",
175
- cwd: project.rootPath,
176
- projectId: project.projectId,
177
- action: "checkout",
178
- refName: issue.branchName,
179
- },
180
- });
186
+ const title = `Review ${issue.identifier}`;
187
+ try {
188
+ return await paseo.workspaces.create({
189
+ title,
190
+ source: {
191
+ kind: "worktree",
192
+ cwd: project.rootPath,
193
+ projectId: project.projectId,
194
+ action: "checkout",
195
+ refName: issue.branchName,
196
+ },
197
+ });
198
+ } catch (error) {
199
+ if (!isUnknownBranch(error)) throw error;
200
+ return createIssueWorktree(paseo, request, title);
201
+ }
181
202
  }
182
203
  if (request.placement === "agent-workspace" && request.agentWorkspace) {
183
204
  return paseo.workspaces.ref(request.agentWorkspace.id);
@@ -203,6 +224,12 @@ export function useLaunchAgent() {
203
224
  labels: { [AGENT_LABELS.issue]: issue.identifier, [AGENT_LABELS.action]: action },
204
225
  });
205
226
  lastProjectByTeam.set(issue.team.id, request.project.projectId);
227
+ rememberLaunch({
228
+ agent: request.agent.agent.id,
229
+ model: request.agent.model.id,
230
+ effort: request.agent.effort?.id ?? null,
231
+ mode: request.agent.mode?.id ?? null,
232
+ });
206
233
  let warning: string | null = null;
207
234
  if (Object.keys(request.patch).length > 0) {
208
235
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yannelli/paseo-linear-plugin",
3
- "version": "0.4.0",
3
+ "version": "1.0.1",
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",
package/paseo-plugin.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "id": "linear",
2
+ "id": "paseo-linear-plugin",
3
3
  "description": "Browse, triage, and create Linear issues, and start agents that implement or review them",
4
4
  "requirements": {
5
5
  "paseo": ">=0.10.0"