@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 +8 -5
- package/client/agent-options.ts +12 -2
- package/client/agent-preferences.ts +112 -0
- package/client/launch-choices.ts +3 -1
- package/client/launch-plan.ts +52 -25
- package/package.json +1 -1
- package/paseo-plugin.json +1 -1
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
|
|
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
|
|
package/client/agent-options.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
+
}
|
package/client/launch-choices.ts
CHANGED
|
@@ -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);
|
package/client/launch-plan.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
package/paseo-plugin.json
CHANGED