@yannelli/paseo-linear-plugin 1.0.0 → 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 (47) hide show
  1. package/OVERVIEW.md +19 -1
  2. package/README.md +77 -6
  3. package/client/access.tsx +66 -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/graph-labels.ts +137 -0
  8. package/client/launch-choices.ts +18 -5
  9. package/client/launch-plan.ts +88 -39
  10. package/client/launch.tsx +9 -4
  11. package/client/live-agents.tsx +407 -0
  12. package/client/live-cursor.tsx +291 -0
  13. package/client/live-feed.tsx +151 -0
  14. package/client/live-graph.tsx +434 -0
  15. package/client/live-header.tsx +312 -0
  16. package/client/live-issues.tsx +345 -0
  17. package/client/live-map.tsx +345 -0
  18. package/client/live-panel.tsx +444 -0
  19. package/client/live-timeline.ts +341 -0
  20. package/client/pill.tsx +393 -0
  21. package/client/project-init.tsx +168 -0
  22. package/client/settings-access.tsx +56 -0
  23. package/client/settings-live.tsx +121 -0
  24. package/client/settings-projects.tsx +9 -0
  25. package/index.client.tsx +39 -1
  26. package/index.server.ts +24 -4
  27. package/package.json +1 -1
  28. package/server/agent-runs.ts +299 -0
  29. package/server/handlers.ts +12 -1
  30. package/server/init.ts +64 -0
  31. package/server/links.ts +153 -0
  32. package/server/live.ts +334 -0
  33. package/server/stores.ts +119 -0
  34. package/server/sync.ts +127 -0
  35. package/shared/activity.ts +308 -0
  36. package/shared/graph-geometry.ts +122 -0
  37. package/shared/graph-model.ts +319 -0
  38. package/shared/links.ts +252 -0
  39. package/shared/live.ts +200 -0
  40. package/shared/map-model.ts +192 -0
  41. package/shared/project-setup.ts +55 -0
  42. package/shared/prompts.ts +16 -1
  43. package/shared/settings.ts +57 -1
  44. package/shared/shell-activity.ts +309 -0
  45. package/shared/shell-lexer.ts +180 -0
  46. package/shared/subagents.ts +106 -0
  47. package/shared/todo-sync.ts +158 -0
package/OVERVIEW.md CHANGED
@@ -25,10 +25,28 @@ Select **Start agent** or **Start review** on an issue. The setup page shows the
25
25
 
26
26
  The prompt follows the template until you edit it. **Reset prompt** restores it. Edit the templates in **Prompts**, and add project instructions in **Projects**.
27
27
 
28
+ ## Linear Live
29
+
30
+ Open **Linear Live** from the Linear pill in an agent's composer. It shows the issue's sub-issues with their statuses and the agent's todos, a map of the files the agent reads and edits, or a graph of those files joined by their imports and links, with the agent gliding to what it works on, the agents it starts, and its latest commands. It works with Claude, Codex, and other providers. The map comes from paths in the ticket text, or, if you choose it in **Settings → Plugins → Linear → Live**, from an explore agent that reads the repository before the agent starts. The implement agent waits for the map. The explore agent uses provider usage. Explore and setup agents are told to only read. The plugin starts Claude in Always Ask and answers the permission requests these agents raise: it allows reads and search commands in the project folder and denies the rest. Codex has no read-only mode, so it runs in its default mode, and the plugin answers only the requests Codex raises.
31
+
32
+ On agents not started from an issue, the Linear pill searches Linear and sends an issue to the agent.
33
+
34
+ ## Update Linear from todos
35
+
36
+ Turn on **Update Linear from agent todos** in the Live settings. After each turn, a todo that starts with a sub-issue key, such as `ENG-124: add the queue`, moves that sub-issue to In Progress, and then to Done when all its todos are done. The parent moves to In Review when every sub-issue is done, never to Done. Statuses only move forward.
37
+
38
+ ## Set up project prompts
39
+
40
+ In **Projects**, select **Start** under **Set up with an agent**. An agent reads the repository's guidelines, scripts, and CI files and proposes project instructions, steps, and prompt additions. You accept each change before it is saved.
41
+
42
+ ## Choose projects
43
+
44
+ In **Projects**, **Use Linear in** turns Linear on or off for all projects, and each project has its own switch. In a project where Linear is off, its panels show that Linear is off, its agents get no Linear pill, and the agent setup page does not offer it. Todo sync and the explore agent skip it.
45
+
28
46
  ## Attach an issue
29
47
 
30
48
  Choose **Attach Linear issue** in the composer attachment menu. The agent gets the issue text with your message. This uses the default key.
31
49
 
32
50
  ## Data and permissions
33
51
 
34
- Only the daemon sends requests to `https://api.linear.app/graphql`. The daemon never sends a key back to the app. Paseo sends the prompt, with the issue snapshot, to the agent provider. The daemon keeps recent Linear responses in memory for up to 24 hours, and the list shows them with **Showing saved results. Updating…** while it gets new data.
52
+ Only the daemon sends requests to `https://api.linear.app/graphql`. The daemon never sends a key back to the app. Paseo sends the prompt, with the issue snapshot, to the agent provider. Explore and setup agents read project files and send them to your agent provider. The daemon saves explore maps, and agents that wait for a map, beside the key file, and Linear Live lists and reads files only inside the agent's folder. With todo sync on, the daemon changes issue statuses in Linear. The daemon keeps recent Linear responses in memory for up to 24 hours, and the list shows them with **Showing saved results. Updating…** while it gets new data.
package/README.md CHANGED
@@ -13,6 +13,11 @@ A Paseo plugin for Linear. It adds a Linear screen and a workspace panel where y
13
13
  - Use a different Linear key for a Paseo project.
14
14
  - See saved results at once while the plugin gets new data from Linear.
15
15
  - Attach a Linear issue to a message in the composer.
16
+ - Follow an agent in **Linear Live**: sub-issues, todos, and a map of the files it reads and edits.
17
+ - Open the issue from a **Linear** pill in each agent's composer, or send an issue to any agent.
18
+ - Let agent todos move sub-issues to In Progress and Done. This is off until you turn it on.
19
+ - Let an agent read a project's guidelines and propose its instructions and steps.
20
+ - Turn Linear on or off for all projects, or project by project.
16
21
 
17
22
  ![Linear screen with an issue list grouped by status on the left and issue ENG-123 open on the right](docs/images/browse.png)
18
23
 
@@ -103,16 +108,17 @@ Select **Start agent** or **Start review** in the issue view. The agent setup pa
103
108
  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.
104
109
  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.
105
110
  - The model picker lists the providers first. Select a provider to see its models, or search to find models from all providers.
106
- - Choosing a model from another provider also changes the agent, and resets the thinking level and mode to that agent's defaults.
111
+ - 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.
112
+ - 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.
107
113
  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.
108
114
  4. Choose where the agent runs in **Run in**:
109
115
  - **New worktree** on the issue branch.
110
116
  - **Check out PR #N** for a review, when the issue links a GitHub pull request.
111
- - **Check out issue branch** for a review.
117
+ - **Check out issue branch** for a review. If nobody pushed the branch yet, the plugin starts a new worktree on it.
112
118
  - **Existing agent workspace** or **Implementation workspace**, when an agent already works on the issue.
113
119
  - **This workspace**, or **Project folder** when you are not in a workspace of that project.
114
120
 
115
- The worktree and checkout options show only for a Git project.
121
+ 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.
116
122
  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.
117
123
  6. Select **Start agent** or **Start review**.
118
124
 
@@ -124,11 +130,66 @@ Set the default model, where agents run, the default options, and the Implement
124
130
 
125
131
  In the composer, open the attachment menu and choose **Attach Linear issue**. Type an identifier such as `ENG-123`, or words from a title. An empty search lists the 20 most recently updated issues. The agent gets the issue text with your message. The plugin takes this snapshot when you select the issue.
126
132
 
133
+ ### Turn Linear on or off per project
134
+
135
+ In **Settings → Plugins → Linear → Projects**, **Use Linear in** has an **All projects** switch and one switch per Paseo project. A project switch wins over **All projects**. A project without its own switch follows **All projects**. Save to apply.
136
+
137
+ In a project where Linear is off:
138
+
139
+ - The workspace **Linear** panel and **Linear Live** show that Linear is off.
140
+ - Agents get no Linear pill.
141
+ - The agent setup page does not offer the project.
142
+ - Todo sync and the explore agent skip the project's agents.
143
+
144
+ The **Linear** screen in the sidebar is not tied to a project, so it stays available. To turn off the whole plugin, use **Settings → Plugins**.
145
+
146
+ ### Linear Live
147
+
148
+ Linear Live is an agent panel. Open it from the Linear pill, or with **Linear: Open Linear Live** in the command center while an agent is open. It works for agents started from an issue, with Claude, Codex, and other providers.
149
+
150
+ - **Sub-issues:** each sub-issue shows its Linear status and the agent's todo progress. The todos of the current sub-issue show under it. Select a sub-issue to show only its files on the map. Hide the issues column with the button on the **Issue** card to give the map or the graph the full width. The column becomes a strip with the status of each issue. Select an issue there to show only its files.
151
+ - **Map:** files are grouped by folder. The map marks the files the agent read, edited, or created, and the file it is on now. Files the agent touched but the map did not predict have a dashed border. A folder the agent listed or searched gets a solid border, and its header shows how many of its files the agent touched. Reads through the shell count too: `cat`, `sed -n`, `head`, `grep`, `rg`, `ls`, `find`, `git show`, and redirects to files.
152
+ - **Graph:** the files and the links between them. A line joins two files when one imports the other, a Markdown or style file links to the other, `package.json` names it, or the text of one names the path of the other. This works for TypeScript and JavaScript (with `tsconfig` paths and workspace packages), Python, Go, Rust, Ruby, CSS and Sass, Markdown, HTML, and config files. Files of one folder sit together under the folder name and take the color of their sub-issue. The issue is in the center, with its sub-issues joined to the files the map gives them. The agent hovers over what it works on: the file it reads or edits, or the sub-issue whose todo it just started. When the work moves, it glides along the links to the next node, and the lines at that node take its color. New files join without moving the rest. Agents that this agent started get their own cursor when they work in the same folder. Running subagents circle the agent that started them. Select a sub-issue to show only its files. Select any node to see what it is and how many files it links to. Switch between **Map** and **Graph** on the card, or set the default in **Show files as** in the Live settings.
153
+ - **Agents:** the agents that work under this agent. Paseo agents that this agent started show their status, their files, and their own markers on the map when they work in the same folder. Select one to open it. Subagents that run inside the provider, such as Claude's Agent tool, show their type, description, and status. The provider does not send their file reads, so the map shows only the files named in a subagent's prompt, and only if the panel was open when it started.
154
+ - **Activity:** the latest reads, edits, searches, and commands, with line counts and exit codes.
155
+
156
+ The map comes from one of two sources. Choose it in **Settings → Plugins → Linear → Live**:
157
+
158
+ - **Ticket text** (the default) finds file and folder paths in the issue title, description, and sub-issue titles. It costs nothing, but it finds only paths the issue names. Linear sends sub-issue titles but not their descriptions.
159
+ - **Explore agent** maps the files first when you start an implement agent. It reads the repository and lists the files for each sub-issue. The implement agent starts when the map is ready, or with the ticket-text map if exploring fails. A saved map is used again, so each issue is explored once. The plugin archives the explore agent when it finishes. Choose its model in the Live settings. A faster, cheaper model is usually enough.
160
+
161
+ The daemon starts the waiting agent, so you can close the app while the map is made. If the plugin reloads or the daemon restarts during the run, the plugin finishes it when the explore agent's turn ends. A waiting agent that has not started after one hour is dropped.
162
+
163
+ Explore and setup agents are told to only read. The plugin starts them in a mode that asks before tool use when the provider has one: **Always Ask** for Claude, never a bypass mode. When such an agent asks for permission, the plugin answers. It allows file reads inside the project folder and these commands: `ls`, `find`, `rg`, `grep`, `cat`, `head`, `tail`, `wc`, `git ls-files`, and `git grep`. It denies other commands, edits, web requests, redirection, and paths outside the folder. The plugin sees only the requests the provider raises: Claude runs some read commands without asking, and Codex has no read-only mode, so it runs in its default mode and its own sandbox decides what needs a request.
164
+
165
+ For implement agents on an issue with sub-issues, the prompt asks the agent to start each todo with its sub-issue key, such as `ENG-124: add the queue`. That is how todos map to sub-issues.
166
+
167
+ ### Linear pill
168
+
169
+ Each agent's composer has a **Linear** pill. On an agent started from an issue, the pill shows the issue key. It opens the issue status, each sub-issue with its todo progress, and buttons for Linear Live, the issue, and **Sync now**. On other agents, the pill searches Linear and sends the issue text to the agent as a message.
170
+
171
+ ### Update Linear from agent todos
172
+
173
+ Turn on **Update Linear from agent todos** in the Live settings. After each turn of an implement agent, the daemon reads the agent's todos:
174
+
175
+ - A sub-issue with a todo in progress or done moves to the team's first started status, such as In Progress.
176
+ - A sub-issue whose todos are all done moves to the team's first completed status, such as Done.
177
+ - The parent moves to In Progress when work starts. When every sub-issue is done, it moves to a started status whose name has "review", such as In Review. It never moves to Done by itself.
178
+ - Statuses only move forward. Canceled issues and issues outside the parent and its sub-issues never change.
179
+
180
+ The daemon reads the issue from Linear before each sync, so a status you changed by hand counts. **Sync now** in the pill runs the same check at once.
181
+
182
+ ### Set up project prompts with an agent
183
+
184
+ In **Settings → Plugins → Linear → Projects**, select **Start** under **Set up with an agent**. An agent reads the project's AGENTS.md, CLAUDE.md, CONTRIBUTING, README, scripts, and CI files. It then proposes project instructions, steps, and text to add to the Implement and Review prompts. Each change shows the current and proposed text. Turn off the ones you do not want, select **Use accepted changes**, and then save. Nothing changes until you save.
185
+
127
186
  ## Data and permissions
128
187
 
129
188
  - **Your key stays on the daemon host.** When you save a key, the app sends it to the daemon once. The daemon never sends a key back to the app. The app gets only the last four characters, to show which key is in use. Only the daemon sends requests to `https://api.linear.app/graphql`.
130
189
  - **Data sent to Linear:** queries for issues, teams, users, and comments, and the changes you make. When you start an agent with the options on, the plugin moves the issue to In Progress and assigns it to you.
131
190
  - **Data sent to the agent provider:** the prompt. It holds the template text and an issue snapshot: identifier, title, URL, team, status, priority, assignee, project, labels, parent, description, sub-issues, and links. It holds comments only when **Include comments in the prompt** is on. It also holds the project instructions and steps from the Projects settings. An attached issue sends the identifier, title, URL, status, priority, assignee, project, labels, and description.
191
+ - **Linear Live and setup agents:** the explore agent gets the issue snapshot without comments, and both agents read files in the project. Your agent provider gets what they read. The map of an explore run is saved in `live-maps.json` beside the key file, for up to 200 issues. An agent that waits for a map is saved in `pending-launches.json` there until it starts. Linear Live lists files only in the folders it shows, and reads the files on the graph to find their links, inside the agent's own folder. It does not follow links that lead outside that folder.
192
+ - **Todo sync:** when it is on, the daemon changes issue statuses in Linear with the key of the project that loaded the issue.
132
193
  - **Cached results:** the daemon keeps up to 300 recent Linear responses in memory for up to 24 hours. It groups them by a hash of the key, so two keys never share results. The app shows saved results at once with **Showing saved results. Updating…** while it gets new data. An edit clears the cached lists and issues for that key. Saving or removing a key clears the whole cache. The cache is not written to disk, and a daemon restart clears it.
133
194
 
134
195
  Linear errors, such as a rejected key or a rate limit, show in the panel or as a message.
@@ -137,8 +198,8 @@ Linear errors, such as a rejected key or a rate limit, show in the panel or as a
137
198
 
138
199
  | File | Runtime | Role |
139
200
  | --- | --- | --- |
140
- | `index.client.tsx` | App | Registers the Linear screen, the workspace panel, the Account, Prompts, and Projects settings, the command center items, the `/linear` command, the timeline card, and the composer attachment |
141
- | `index.server.ts` | Daemon | Registers the settings and the RPC handlers |
201
+ | `index.client.tsx` | App | Registers the Linear screen, the workspace panel, the Linear Live panel, the Account, Prompts, Live, and Projects settings, the command center items, the `/linear` command, the timeline card, the composer attachment, and the Linear pill |
202
+ | `index.server.ts` | Daemon | Registers the settings, the RPC handlers, and the todo sync hook |
142
203
  | `client/browser.tsx` | App | Screen and panel: picks the key, then shows the list, the issue, or the agent setup page |
143
204
  | `client/issue-list.tsx`, `client/issue-row.tsx`, `client/issue-tree.ts` | App | Search, filters, sort, rows, status groups, and sub-issue nesting |
144
205
  | `client/issue-detail.tsx`, `client/issue-sections.tsx`, `client/breadcrumbs.tsx` | App | Issue view: properties, breadcrumbs, sub-issues, links, agents, and comments |
@@ -151,16 +212,26 @@ Linear errors, such as a rejected key or a rate limit, show in the panel or as a
151
212
  | `client/queries.ts`, `client/store.ts`, `client/key-scope.tsx` | App | Data hooks with saved results, browser state, and the key in use |
152
213
  | `client/compat.ts` | App | Registers the screen on Paseo 0.10 and on later releases |
153
214
  | `client/timeline-card.tsx`, `client/ui.tsx`, `client/glyphs.tsx` | App | Timeline card, shared controls, and icons |
215
+ | `client/live-panel.tsx`, `client/live-header.tsx`, `client/live-issues.tsx`, `client/live-feed.tsx`, `client/live-timeline.ts` | App | Linear Live panel, header, sub-issues, activity, and the agent timeline feed |
216
+ | `client/live-map.tsx`, `client/live-graph.tsx`, `client/graph-labels.ts`, `client/live-cursor.tsx`, `client/live-agents.tsx` | App | File map, file graph and its labels, agent cursors, and the agents list |
217
+ | `client/pill.tsx` | App | Linear pill in each agent's composer |
218
+ | `client/project-init.tsx` | App | Project setup proposal in the Projects settings |
154
219
  | `shared/linear.ts` | Both | RPC contracts and issue schemas |
155
220
  | `shared/issues.ts` | Both | The `issues.search` RPC and the composer attachment source |
156
221
  | `shared/settings.ts` | Both | Settings schema: templates, agent defaults, and project settings |
157
222
  | `shared/prompts.ts` | Both | Default templates and the issue snapshot |
158
223
  | `shared/markdown.ts` | Both | Markdown parser and task list toggle |
224
+ | `shared/live.ts`, `shared/activity.ts`, `shared/subagents.ts` | Both | Linear Live contracts, the ticket text map, timeline activity, and subagent runs |
225
+ | `shared/shell-lexer.ts`, `shared/shell-activity.ts` | Both | Shell command parsing for reads, edits, and searches |
226
+ | `shared/map-model.ts`, `shared/graph-model.ts`, `shared/graph-geometry.ts`, `shared/links.ts` | Both | Map folders, the file graph with its layout and paths, label placement, and the parsers that find links in files |
227
+ | `shared/todo-sync.ts`, `shared/project-setup.ts` | Both | Todo keys, status moves, and setup proposals |
159
228
  | `server/handlers.ts` | Daemon | RPC handlers, key lookup, and cache use |
160
229
  | `server/credentials.ts` | Daemon | Key file and key order |
161
230
  | `server/cache.ts` | Daemon | Response cache in memory |
162
231
  | `server/graphql.ts`, `server/queries.ts` | Daemon | Linear GraphQL client, queries, and changes |
163
232
  | `server/linear.ts` | Daemon | Search and snapshot text for the composer attachment |
233
+ | `server/live.ts`, `server/links.ts`, `server/agent-runs.ts` | Daemon | File listing, links between files, explore runs, and the saved maps |
234
+ | `server/sync.ts`, `server/init.ts` | Daemon | Todo sync after each turn, and project setup runs |
164
235
 
165
236
  The client bundle holds no credentials and makes no calls to Linear.
166
237
 
@@ -176,7 +247,7 @@ npm run check
176
247
  paseo plugin add "$PWD"
177
248
  ```
178
249
 
179
- 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.
250
+ 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, the links between files in many languages, the graph layout, and the release scripts. They use a local GraphQL server and do not call Linear.
180
251
 
181
252
  ## Graphics
182
253
 
@@ -0,0 +1,66 @@
1
+ import { type PluginAgentPanelProps, useSettings, useWorkspace } from "@getpaseo/plugin/client";
2
+ import { type ComponentType, type ReactNode, useEffect } from "react";
3
+ import { linearSettings, type ProjectAccess, projectEnabled } from "../shared/settings";
4
+ import { EmptyState, type Theme } from "./ui";
5
+
6
+ // Composer pills live outside React, so components that read settings share the project
7
+ // switches here, and the pill code listens for changes.
8
+ let current: ProjectAccess | null = null;
9
+ const listeners = new Set<(access: ProjectAccess) => void>();
10
+
11
+ export function publishAccess(access: ProjectAccess): void {
12
+ if (current && JSON.stringify(current) === JSON.stringify(access)) return;
13
+ current = access;
14
+ for (const listener of listeners) listener(access);
15
+ }
16
+
17
+ export function currentAccess(): ProjectAccess | null {
18
+ return current;
19
+ }
20
+
21
+ export function onAccessChange(listener: (access: ProjectAccess) => void): () => void {
22
+ listeners.add(listener);
23
+ return () => listeners.delete(listener);
24
+ }
25
+
26
+ /** Null while settings load or when the project is unknown, so callers can wait. */
27
+ export function useProjectEnabled(projectId: string | null | undefined): boolean | null {
28
+ const settings = useSettings(linearSettings);
29
+ const access = settings.status === "ready" ? settings.values.access : null;
30
+ useEffect(() => {
31
+ if (access) publishAccess(access);
32
+ }, [access]);
33
+ if (!access || projectId === undefined) return null;
34
+ return projectEnabled(access, projectId);
35
+ }
36
+
37
+ export function ProjectGate(props: {
38
+ theme: Theme;
39
+ projectId: string | null | undefined;
40
+ children: ReactNode;
41
+ }) {
42
+ const enabled = useProjectEnabled(props.projectId);
43
+ if (enabled !== false) return <>{props.children}</>;
44
+ return (
45
+ <EmptyState
46
+ theme={props.theme}
47
+ icon="SquareKanban"
48
+ title="Linear is off for this project"
49
+ detail="Turn it on in Linear settings, under Projects."
50
+ />
51
+ );
52
+ }
53
+
54
+ const selectProjectId = (workspace: { projectId: string }) => workspace.projectId;
55
+
56
+ /** Wraps an agent panel so it shows the off state in projects where Linear is off. */
57
+ export function gateAgentPanel(Panel: ComponentType<PluginAgentPanelProps>) {
58
+ return function GatedAgentPanel(props: PluginAgentPanelProps) {
59
+ const projectId = useWorkspace(props.workspaceId, selectProjectId);
60
+ return (
61
+ <ProjectGate theme={props.theme} projectId={projectId}>
62
+ <Panel {...props} />
63
+ </ProjectGate>
64
+ );
65
+ };
66
+ }
@@ -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
+ }
@@ -11,6 +11,7 @@ import { ConnectCard } from "./connect";
11
11
  import { CreateIssueModal } from "./create-issue";
12
12
  import { IssueDetailView } from "./issue-detail";
13
13
  import { IssueList } from "./issue-list";
14
+ import { ProjectGate } from "./access";
14
15
  import { effectiveKeyScope, KeyScopeProvider } from "./key-scope";
15
16
  import { LaunchPage, type LaunchTarget } from "./launch";
16
17
  import { useAuthStatus, useCatalog, useProjects } from "./queries";
@@ -262,14 +263,16 @@ export function LinearPanel({ theme, layout, navigation, workspaceId }: PluginWo
262
263
  if (teamId) update({ teamId, assignee: "anyone" });
263
264
  }, [settings, projectId, update]);
264
265
  return (
265
- <IssueBrowser
266
- theme={theme}
267
- compact={layout.compact}
268
- navigation={navigation}
269
- scope={scope}
270
- target={target}
271
- keyProjectId={projectId ?? null}
272
- canSwitchKey={false}
273
- />
266
+ <ProjectGate theme={theme} projectId={projectId}>
267
+ <IssueBrowser
268
+ theme={theme}
269
+ compact={layout.compact}
270
+ navigation={navigation}
271
+ scope={scope}
272
+ target={target}
273
+ keyProjectId={projectId ?? null}
274
+ canSwitchKey={false}
275
+ />
276
+ </ProjectGate>
274
277
  );
275
278
  }
@@ -0,0 +1,137 @@
1
+ import { type LabelRequest, labelBox, labelSides, type Placed, type Point, placeLabels } from "../shared/graph-geometry";
2
+ import { folderCenters, type Graph, type GraphNode } from "../shared/graph-model";
3
+ import type { IssueDetail } from "../shared/linear";
4
+ import { todoStatus } from "../shared/todo-sync";
5
+ import type { IssueProgress } from "./live-issues";
6
+
7
+ // Which labels the graph shows and where: issues first, then folder names, then the files the
8
+ // agent works on, then touched files. A label that fits nowhere is hidden.
9
+
10
+ const SIZES = { issue: 46, subissue: 32 } as const;
11
+ export const HOVER_GAP = 22;
12
+
13
+ /** About how wide a cursor caption draws. */
14
+ export const captionWidth = (text: string | null) => (text ? Math.min(190, text.length * 6.2 + 18) : 0);
15
+ const MAX_LABEL = 170;
16
+
17
+ export function nodeSize(node: GraphNode): number {
18
+ if (node.kind !== "file") return SIZES[node.kind];
19
+ const touched = node.tile && node.tile.touch !== "none";
20
+ return touched ? (node.tile?.touch === "read" ? 12 : 14) : 9;
21
+ }
22
+
23
+ function clip(text: string, max: number): string {
24
+ return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
25
+ }
26
+
27
+ export interface Label {
28
+ left: number;
29
+ top: number;
30
+ width: number;
31
+ lines: readonly [string, string | null];
32
+ }
33
+
34
+ export interface FolderLabel {
35
+ folder: string;
36
+ left: number;
37
+ top: number;
38
+ width: number;
39
+ }
40
+
41
+ export interface LabelInput {
42
+ graph: Graph;
43
+ positions: ReadonlyMap<string, Placed>;
44
+ issue: IssueDetail;
45
+ progress: IssueProgress;
46
+ selected: string | null;
47
+ /** Nodes a running agent works on. */
48
+ working: ReadonlySet<string>;
49
+ /** Where each agent hovers, with the width of its caption and the side it shows on. */
50
+ cursors: readonly { at: Point; caption: number; flip: boolean }[];
51
+ width: number;
52
+ height: number;
53
+ }
54
+
55
+ function linesOf(node: GraphNode, input: LabelInput): readonly [string, string | null] {
56
+ if (node.kind === "issue") return [node.label, clip(input.issue.title, 34)];
57
+ if (node.kind === "file") return [node.label, null];
58
+ const child = input.issue.children.find((entry) => entry.identifier.toUpperCase() === node.label);
59
+ const todos = input.progress.groups.get(node.label) ?? [];
60
+ const done = todos.filter((todo) => todoStatus(todo) === "completed").length;
61
+ const counts = todos.length > 0 ? ` ${done}/${todos.length}` : "";
62
+ const folded = node.folded > 0 ? ` +${node.folded}` : "";
63
+ return [`${node.label}${counts}${folded}`, clip(child?.title ?? "", 26)];
64
+ }
65
+
66
+ const textWidth = (text: string, perChar: number) => Math.min(MAX_LABEL, text.length * perChar + 4);
67
+
68
+ export function graphLabels(input: LabelInput): { nodes: Map<string, Label>; folders: FolderLabel[] } {
69
+ const { graph, positions, selected, working } = input;
70
+ const order = (node: GraphNode) =>
71
+ node.id === selected ? 0 : node.kind === "issue" ? 1 : node.kind === "subissue" ? 2 : working.has(node.id) ? 4 : 5;
72
+ const wanted = graph.nodes
73
+ .filter((node) => node.kind !== "file" || node.id === selected || working.has(node.id) || (node.tile?.touch ?? "none") !== "none")
74
+ .sort((a, b) => order(a) - order(b));
75
+ const lines = new Map<string, readonly [string, string | null]>();
76
+ const requests: LabelRequest[] = [];
77
+ const push = (node: GraphNode) => {
78
+ const at = positions.get(node.id);
79
+ if (!at) return;
80
+ const text = linesOf(node, input);
81
+ const perChar = node.kind === "issue" ? 7.4 : 6.2;
82
+ const width = Math.max(textWidth(text[0], perChar), text[1] ? textWidth(text[1], 5.6) : 0);
83
+ const height = text[1] ? 28 : 14;
84
+ const spotsFor = (w: number, h: number) =>
85
+ labelSides(at.side).map((side) => labelBox({ ...at, side }, nodeSize(node) / 2, w, h));
86
+ const short = textWidth(text[0], perChar);
87
+ lines.set(node.id, text);
88
+ requests.push({
89
+ id: node.id,
90
+ width,
91
+ height,
92
+ spots: spotsFor(width, height),
93
+ fallback: text[1] ? { width: short, height: 14, spots: spotsFor(short, 14) } : undefined,
94
+ });
95
+ };
96
+ for (const node of wanted.filter((entry) => order(entry) <= 2)) push(node);
97
+ // Folder names sit above each neighborhood, after the issues and before single files.
98
+ const folderIds = new Map<string, string>();
99
+ for (const center of folderCenters(graph, positions)) {
100
+ const name = center.folder === "." ? "/" : `${center.folder}/`;
101
+ const width = textWidth(name, 6);
102
+ const id = `folder:${center.folder}`;
103
+ folderIds.set(id, center.folder);
104
+ requests.push({ id, width, height: 14, spots: [{ left: center.x - width / 2, top: center.top - 30 }, { left: center.x - width / 2, top: center.y - 7 }] });
105
+ }
106
+ for (const node of wanted.filter((entry) => order(entry) > 2)) push(node);
107
+ const shapes: { at: Point; radius: number }[] = graph.nodes.flatMap((node) => {
108
+ const at = positions.get(node.id);
109
+ return at ? [{ at, radius: nodeSize(node) / 2 }] : [];
110
+ });
111
+ // Keep labels out from under the agents and their captions, as a row of small circles.
112
+ for (const cursor of input.cursors) {
113
+ shapes.push({ at: cursor.at, radius: 16 });
114
+ for (let offset = 24; offset < cursor.caption + 24; offset += 14) {
115
+ shapes.push({ at: { x: cursor.at.x + (cursor.flip ? -offset : offset), y: cursor.at.y }, radius: 10 });
116
+ }
117
+ }
118
+ const sizes = new Map(requests.map((request) => [request.id, request]));
119
+ const nodes = new Map<string, Label>();
120
+ const folders: FolderLabel[] = [];
121
+ for (const [id, box] of placeLabels(requests, input.width, input.height, shapes)) {
122
+ const request = sizes.get(id) as LabelRequest;
123
+ const folder = folderIds.get(id);
124
+ if (folder !== undefined) {
125
+ folders.push({ folder, left: box.left, top: box.top, width: request.width });
126
+ continue;
127
+ }
128
+ const text = lines.get(id) ?? ["", null];
129
+ nodes.set(id, {
130
+ left: box.left,
131
+ top: box.top,
132
+ width: (box.short ? request.fallback?.width : request.width) ?? 0,
133
+ lines: box.short ? [text[0], null] : text,
134
+ });
135
+ }
136
+ return { nodes, folders };
137
+ }
@@ -2,8 +2,9 @@ import { usePaseo } from "@getpaseo/plugin/client";
2
2
  import { useQuery } from "@tanstack/react-query";
3
3
  import { useCallback, useMemo, useState } from "react";
4
4
  import type { IssueDetail } from "../shared/linear";
5
- import type { AgentAction, LinearSettings } from "../shared/settings";
5
+ import { type AgentAction, type LinearSettings, projectEnabled } 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,
@@ -40,7 +41,15 @@ export function useLaunchChoices(input: {
40
41
  target: TargetWorkspace | null;
41
42
  }) {
42
43
  const { issue, settings, action } = input;
43
- const projects = useProjects();
44
+ const allProjects = useProjects();
45
+ // Projects where Linear is off are not offered as launch targets.
46
+ const projects = useMemo(
47
+ () => ({
48
+ ...allProjects,
49
+ data: allProjects.data?.filter((entry) => projectEnabled(settings.access, entry.projectId)),
50
+ }),
51
+ [allProjects, settings.access],
52
+ );
44
53
  const providers = useProviders();
45
54
  const { target } = input;
46
55
  const prNumber = action === "review" ? pullRequestNumber(issue) : null;
@@ -52,10 +61,14 @@ export function useLaunchChoices(input: {
52
61
  const [projectChoice, setProjectChoice] = useState<string | null>(null);
53
62
  const [placementChoice, setPlacementChoice] = useState<Placement | null>(null);
54
63
  const [picks, setPicks] = useState<AgentPicks>(NO_PICKS);
55
- const projectId =
64
+ const [remembered] = useState(loadAgentPreferences);
65
+ const preferredId =
56
66
  projectChoice ??
57
67
  defaultProjectId({ target, teamId: issue.team.id, settings, projects: projects.data });
58
- const project = projects.data?.find((entry) => entry.projectId === projectId) ?? null;
68
+ const project =
69
+ projects.data?.find((entry) => entry.projectId === preferredId) ??
70
+ (projectChoice ? null : (projects.data?.[0] ?? null));
71
+ const projectId = project?.projectId ?? preferredId;
59
72
  const placements = useMemo(
60
73
  () =>
61
74
  placementOptions({
@@ -75,7 +88,7 @@ export function useLaunchChoices(input: {
75
88
  settings.launch.isolation,
76
89
  );
77
90
  const agents = providers.data ?? [];
78
- const agent = resolveAgent(agents, picks, settings.launch.provider);
91
+ const agent = resolveAgent(agents, picks, settings.launch.provider, remembered);
79
92
  const chooseProject = useCallback((value: string) => {
80
93
  setProjectChoice(value);
81
94
  setPlacementChoice(null);