@yannelli/paseo-linear-plugin 1.0.0 → 1.1.0-alpha.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 (65) hide show
  1. package/OVERVIEW.md +32 -2
  2. package/README.md +138 -11
  3. package/client/access.tsx +67 -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/compat.ts +23 -5
  8. package/client/connect.tsx +3 -2
  9. package/client/launch-assignments.tsx +182 -0
  10. package/client/launch-choices.ts +23 -6
  11. package/client/launch-guidance.tsx +103 -0
  12. package/client/launch-plan.ts +166 -42
  13. package/client/launch.tsx +55 -9
  14. package/client/live-agents.tsx +446 -0
  15. package/client/live-feed.tsx +151 -0
  16. package/client/live-header.tsx +310 -0
  17. package/client/live-issues.tsx +345 -0
  18. package/client/live-map.tsx +351 -0
  19. package/client/live-panel.tsx +392 -0
  20. package/client/live-timeline.ts +379 -0
  21. package/client/pill.tsx +394 -0
  22. package/client/plugin-icon.tsx +75 -0
  23. package/client/project-init.tsx +192 -0
  24. package/client/project-knowledge.tsx +87 -0
  25. package/client/settings-access.tsx +56 -0
  26. package/client/settings-agents.tsx +113 -0
  27. package/client/settings-fields.tsx +11 -3
  28. package/client/settings-icon.tsx +277 -0
  29. package/client/settings-live.tsx +128 -0
  30. package/client/settings-overrides.tsx +148 -0
  31. package/client/settings-projects.tsx +26 -4
  32. package/client/settings-prompts.tsx +3 -3
  33. package/client/timeline-card.tsx +2 -1
  34. package/client/tool-buttons.tsx +68 -0
  35. package/client/ui.tsx +16 -7
  36. package/client/web.ts +81 -0
  37. package/index.client.tsx +160 -51
  38. package/index.server.ts +32 -5
  39. package/package.json +1 -1
  40. package/server/agent-hooks.ts +37 -0
  41. package/server/agent-runs.ts +312 -0
  42. package/server/agent-tools.ts +276 -0
  43. package/server/handlers.ts +12 -1
  44. package/server/init.ts +89 -0
  45. package/server/inspect.ts +392 -0
  46. package/server/knowledge.ts +157 -0
  47. package/server/live.ts +371 -0
  48. package/server/mcp-http.ts +156 -0
  49. package/server/stores.ts +126 -0
  50. package/server/subagent-logs.ts +146 -0
  51. package/server/sync.ts +133 -0
  52. package/shared/activity.ts +268 -0
  53. package/shared/agent-hooks.ts +68 -0
  54. package/shared/agent-tools.ts +157 -0
  55. package/shared/custom-icon.ts +188 -0
  56. package/shared/knowledge.ts +255 -0
  57. package/shared/live.ts +248 -0
  58. package/shared/map-model.ts +240 -0
  59. package/shared/project-setup.ts +56 -0
  60. package/shared/prompts.ts +109 -2
  61. package/shared/settings.ts +145 -3
  62. package/shared/shell-activity.ts +309 -0
  63. package/shared/shell-lexer.ts +180 -0
  64. package/shared/subagents.ts +106 -0
  65. package/shared/todo-sync.ts +158 -0
@@ -1,10 +1,11 @@
1
1
  import { useRpc } from "@getpaseo/plugin/client";
2
- import { Icon, TextInput, useToast } from "@getpaseo/plugin/client/react-native";
2
+ import { TextInput, useToast } from "@getpaseo/plugin/client/react-native";
3
3
  import { ExternalLink } from "@getpaseo/plugin/client/ui";
4
4
  import { useMutation, useQueryClient } from "@tanstack/react-query";
5
5
  import { useCallback, useMemo, useState } from "react";
6
6
  import { Text, View } from "react-native";
7
7
  import { authSaveRpc } from "../shared/linear";
8
+ import { PluginIcon } from "./plugin-icon";
8
9
  import { Button, errorMessage, type Theme } from "./ui";
9
10
 
10
11
  export const LINEAR_KEY_SETTINGS_URL = "https://linear.app/settings/account/security";
@@ -121,7 +122,7 @@ export function ConnectCard({ theme, compact }: { theme: Theme; compact: boolean
121
122
  <View style={styles.root}>
122
123
  <View style={styles.card}>
123
124
  <View style={styles.header}>
124
- <Icon name="SquareKanban" size={22} color={colors.foreground} />
125
+ <PluginIcon size={22} color={colors.foreground} theme={theme} />
125
126
  <Text style={styles.title}>Connect Linear</Text>
126
127
  </View>
127
128
  <Text style={styles.body}>
@@ -0,0 +1,182 @@
1
+ import { Icon } from "@getpaseo/plugin/client/react-native";
2
+ import { useCallback, useMemo, useState } from "react";
3
+ import { Text, View } from "react-native";
4
+ import type { IssueDetail } from "../shared/linear";
5
+ import { type AgentPicks, optionLabel, resolveAgent } from "./agent-options";
6
+ import type { LaunchChoices } from "./launch-choices";
7
+ import { Chip } from "./launch-fields";
8
+ import { agentLabel, type SubIssueAgent } from "./launch-plan";
9
+ import { ModelBrowser } from "./model-browser";
10
+ import { PickerModal } from "./pickers";
11
+ import { ProviderIcon } from "./provider-icon";
12
+ import { IconButton, type Theme } from "./ui";
13
+
14
+ // The user picks an agent for some sub-issues. The agent that starts gets them in its prompt
15
+ // and hands each one off with start_agent, which runs it on the chosen agent.
16
+
17
+ type Picks = Record<string, AgentPicks>;
18
+
19
+ /** The agents chosen for sub-issues, in the order of the sub-issues. */
20
+ export function useSubIssueAgents(issue: IssueDetail, choices: LaunchChoices) {
21
+ const [picks, setPicks] = useState<Picks>({});
22
+ const { agents, remembered } = choices;
23
+ const leadAgent = choices.agent?.agent.id ?? null;
24
+ const leadMode = choices.agent?.mode?.id ?? null;
25
+ const assignments = useMemo((): SubIssueAgent[] => {
26
+ const result: SubIssueAgent[] = [];
27
+ for (const child of issue.children) {
28
+ const pick = picks[child.identifier];
29
+ if (!pick) continue;
30
+ // An agent of the same provider as the lead runs in the lead's mode.
31
+ const mode = pick.mode ?? (pick.agent === leadAgent ? leadMode : null);
32
+ const agent = resolveAgent(agents, { ...pick, mode }, "", remembered);
33
+ if (agent) result.push({ identifier: child.identifier, agent });
34
+ }
35
+ return result;
36
+ }, [issue.children, picks, agents, remembered, leadAgent, leadMode]);
37
+ const choose = useCallback((identifier: string, pick: AgentPicks | null) => {
38
+ setPicks((current) => {
39
+ const { [identifier]: _previous, ...others } = current;
40
+ return pick ? { ...others, [identifier]: pick } : others;
41
+ });
42
+ }, []);
43
+ return { assignments, picks, choose };
44
+ }
45
+
46
+ type Open = { identifier: string; kind: "model" | "effort" } | null;
47
+
48
+ export function SubIssueAgents(props: {
49
+ theme: Theme;
50
+ issue: IssueDetail;
51
+ choices: LaunchChoices;
52
+ state: ReturnType<typeof useSubIssueAgents>;
53
+ /** Why the section cannot be used, or null. */
54
+ blocked: string | null;
55
+ }) {
56
+ const { theme, issue, choices, state, blocked } = props;
57
+ const { colors } = theme;
58
+ const [open, setOpen] = useState<Open>(null);
59
+ const styles = useMemo(
60
+ () => ({
61
+ hint: { color: colors.foregroundMuted, fontSize: 12 },
62
+ row: {
63
+ flexDirection: "row" as const,
64
+ flexWrap: "wrap" as const,
65
+ alignItems: "center" as const,
66
+ columnGap: 8,
67
+ rowGap: 2,
68
+ paddingVertical: 4,
69
+ },
70
+ issue: { flexDirection: "row" as const, alignItems: "center" as const, gap: 8, flexGrow: 1, flexBasis: 220, minWidth: 0 },
71
+ key: { color: colors.foregroundMuted, fontSize: 12, fontWeight: "500" as const },
72
+ title: { flex: 1, color: colors.foreground, fontSize: 14 },
73
+ controls: { flexDirection: "row" as const, alignItems: "center" as const, gap: 2 },
74
+ }),
75
+ [colors],
76
+ );
77
+ const byKey = useMemo(
78
+ () => new Map(state.assignments.map((entry) => [entry.identifier, entry.agent])),
79
+ [state.assignments],
80
+ );
81
+ const close = useCallback(() => setOpen(null), []);
82
+ const { choose } = state;
83
+ const selectModel = useCallback(
84
+ (agentId: string, modelId: string) => {
85
+ if (open) choose(open.identifier, { agent: agentId, model: modelId, effort: null, mode: null });
86
+ setOpen(null);
87
+ },
88
+ [open, choose],
89
+ );
90
+ const selectEffort = useCallback(
91
+ (effort: string) => {
92
+ const pick = open ? state.picks[open.identifier] : undefined;
93
+ if (open && pick) choose(open.identifier, { ...pick, effort });
94
+ setOpen(null);
95
+ },
96
+ [open, state.picks, choose],
97
+ );
98
+ if (blocked) return <Text style={styles.hint}>{blocked}</Text>;
99
+ const muted = colors.foregroundMuted;
100
+ const current = open ? byKey.get(open.identifier) : undefined;
101
+ return (
102
+ <View>
103
+ <Text style={styles.hint}>
104
+ Choose an agent for a sub-issue, and the agent you start hands that sub-issue to it. It does the other sub-issues itself, or hands them off when Hand off work to Paseo agents is on.
105
+ </Text>
106
+ {issue.children.map((child) => {
107
+ const agent = byKey.get(child.identifier);
108
+ const label = agent ? `${agent.agent.label} · ${agent.model.label}` : "Not assigned";
109
+ return (
110
+ <View key={child.identifier} style={styles.row}>
111
+ <View style={styles.issue}>
112
+ <Text style={styles.key}>{child.identifier}</Text>
113
+ <Text style={styles.title} numberOfLines={1}>
114
+ {child.title}
115
+ </Text>
116
+ </View>
117
+ <View style={styles.controls}>
118
+ <Chip
119
+ theme={theme}
120
+ icon={
121
+ agent ? (
122
+ <ProviderIcon provider={agent.agent.id} iconSvg={agent.agent.iconSvg} size={16} color={muted} />
123
+ ) : (
124
+ <Icon name="Bot" size={16} color={muted} />
125
+ )
126
+ }
127
+ value={label}
128
+ accessibilityLabel={`Agent for ${child.identifier}: ${label}`}
129
+ disabled={choices.agents.length === 0}
130
+ onPress={() => setOpen({ identifier: child.identifier, kind: "model" })}
131
+ />
132
+ {agent?.effort ? (
133
+ <Chip
134
+ theme={theme}
135
+ icon={<Icon name="Brain" size={16} color={muted} />}
136
+ value={optionLabel(agent.effort)}
137
+ accessibilityLabel={`Thinking for ${child.identifier}: ${optionLabel(agent.effort)}`}
138
+ onPress={() => setOpen({ identifier: child.identifier, kind: "effort" })}
139
+ />
140
+ ) : null}
141
+ {agent ? (
142
+ <IconButton
143
+ theme={theme}
144
+ icon="X"
145
+ label={`Remove the agent for ${child.identifier}`}
146
+ onPress={() => choose(child.identifier, null)}
147
+ />
148
+ ) : null}
149
+ </View>
150
+ </View>
151
+ );
152
+ })}
153
+ {open?.kind === "model" ? (
154
+ <ModelBrowser
155
+ theme={theme}
156
+ agents={choices.agents}
157
+ agentId={current?.agent.id ?? null}
158
+ modelId={current?.model.id ?? null}
159
+ onClose={close}
160
+ onSelect={selectModel}
161
+ />
162
+ ) : null}
163
+ {open?.kind === "effort" && current ? (
164
+ <PickerModal
165
+ theme={theme}
166
+ title={`Thinking for ${open.identifier}`}
167
+ icon="Brain"
168
+ open
169
+ options={current.model.efforts.map((entry) => ({ value: entry.id, label: optionLabel(entry) }))}
170
+ value={current.effort?.id ?? null}
171
+ onClose={close}
172
+ onSelect={selectEffort}
173
+ />
174
+ ) : null}
175
+ </View>
176
+ );
177
+ }
178
+
179
+ /** Names for the prompt: each sub-issue key with its agent. */
180
+ export function promptAssignments(assignments: readonly SubIssueAgent[]) {
181
+ return assignments.map((entry) => ({ identifier: entry.identifier, label: agentLabel(entry.agent) }));
182
+ }
@@ -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, projectDefaults, 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,15 @@ 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;
72
+ const defaults = projectDefaults(settings, projectId);
59
73
  const placements = useMemo(
60
74
  () =>
61
75
  placementOptions({
@@ -72,10 +86,10 @@ export function useLaunchChoices(input: {
72
86
  placements,
73
87
  placementChoice,
74
88
  action,
75
- settings.launch.isolation,
89
+ defaults.launch.isolation,
76
90
  );
77
91
  const agents = providers.data ?? [];
78
- const agent = resolveAgent(agents, picks, settings.launch.provider);
92
+ const agent = resolveAgent(agents, picks, defaults.launch.provider, remembered);
79
93
  const chooseProject = useCallback((value: string) => {
80
94
  setProjectChoice(value);
81
95
  setPlacementChoice(null);
@@ -112,6 +126,9 @@ export function useLaunchChoices(input: {
112
126
  prNumber,
113
127
  project,
114
128
  projectConfig: settings.projects.find((entry) => entry.projectId === projectId) ?? null,
129
+ /** Launch and tool settings with the project's own values applied. */
130
+ defaults,
131
+ remembered,
115
132
  placements,
116
133
  placement,
117
134
  chooseProject,
@@ -0,0 +1,103 @@
1
+ import { useSettings } from "@getpaseo/plugin/client";
2
+ import { useCallback, useMemo, useState } from "react";
3
+ import { Switch, Text, View } from "react-native";
4
+ import {
5
+ GUIDANCE_KEYS,
6
+ type Guidance,
7
+ GuidanceSchema,
8
+ linearSettings,
9
+ type ProjectConfig,
10
+ type ToolSettings,
11
+ withProjectGuidance,
12
+ } from "../shared/settings";
13
+ import type { PaseoProjectOption } from "./queries";
14
+ import type { Theme } from "./ui";
15
+
16
+ // Launch options that add instructions to the prompt. Each project keeps its own choices, so
17
+ // the next launch in the project starts with them.
18
+
19
+ const DEFAULT_GUIDANCE = GuidanceSchema.parse({});
20
+
21
+ const COPY: Record<(typeof GUIDANCE_KEYS)[number], { label: string; hint: string }> = {
22
+ updateLinear: {
23
+ label: "Keep Linear updated",
24
+ hint: "The agent edits the issue description and checks off task list items as it works, with the plugin's Linear tools. It posts no comments. Todos start with issue keys, so status sync can move each sub-issue.",
25
+ },
26
+ subagentKeys: {
27
+ label: "Subagents state their issue key",
28
+ hint: "Subagents open with the key of the issue they work on and state the new key when it changes. Claude agents also get a hook that tells each subagent.",
29
+ },
30
+ paseoSubagents: {
31
+ label: "Hand off work to Paseo agents",
32
+ hint: "The agent starts one Paseo agent for each sub-issue with the plugin's tools, on its own provider and model. Each one checks off its own items, and Linear Live shows its work.",
33
+ },
34
+ };
35
+
36
+ /** The project's guidance, changed locally at once and saved to the project in the background. */
37
+ export function useProjectGuidance(project: PaseoProjectOption | null, config: ProjectConfig | null) {
38
+ const settings = useSettings(linearSettings);
39
+ const [edits, setEdits] = useState<Record<string, Guidance>>({});
40
+ const guidance = (project ? edits[project.projectId] : undefined) ?? config?.guidance ?? DEFAULT_GUIDANCE;
41
+ const change = useCallback(
42
+ (key: (typeof GUIDANCE_KEYS)[number], value: boolean) => {
43
+ if (!project) return;
44
+ const next = { ...guidance, [key]: value };
45
+ setEdits((current) => ({ ...current, [project.projectId]: next }));
46
+ if (settings.status === "ready") void settings.save(withProjectGuidance(settings.values, project, next), settings.revision);
47
+ },
48
+ [project, guidance, settings],
49
+ );
50
+ return { guidance, change };
51
+ }
52
+
53
+ /** The hint of an option whose Linear tools are off or limited, or null for the usual hint. */
54
+ function toolsHint(key: (typeof GUIDANCE_KEYS)[number], tools: ToolSettings): string | null {
55
+ if (key === "paseoSubagents" && !tools.enabled) {
56
+ return "This needs the plugin's Linear tools, which are off for this project. Turn them on in Linear settings, under Agents.";
57
+ }
58
+ if (key === "updateLinear" && !(tools.enabled && tools.allowEdits)) {
59
+ return "Todos start with issue keys, so status sync can move each sub-issue. The agent cannot edit the issue, because the Linear tools or description edits are off for this project.";
60
+ }
61
+ return null;
62
+ }
63
+
64
+ export function GuidanceToggles(props: {
65
+ theme: Theme;
66
+ guidance: Guidance;
67
+ tools: ToolSettings;
68
+ onChange(key: (typeof GUIDANCE_KEYS)[number], value: boolean): void;
69
+ }) {
70
+ const { colors } = props.theme;
71
+ const styles = useMemo(
72
+ () => ({
73
+ row: { flexDirection: "row" as const, alignItems: "center" as const, gap: 10, paddingVertical: 4 },
74
+ body: { flex: 1, gap: 2 },
75
+ label: { color: colors.foreground, fontSize: 14 },
76
+ hint: { color: colors.foregroundMuted, fontSize: 12 },
77
+ track: { true: colors.accent, false: colors.surface2 },
78
+ }),
79
+ [colors],
80
+ );
81
+ return (
82
+ <View>
83
+ {GUIDANCE_KEYS.map((key) => {
84
+ const blocked = key === "paseoSubagents" && !props.tools.enabled;
85
+ return (
86
+ <View key={key} style={styles.row}>
87
+ <View style={styles.body}>
88
+ <Text style={styles.label}>{COPY[key].label}</Text>
89
+ <Text style={styles.hint}>{toolsHint(key, props.tools) ?? COPY[key].hint}</Text>
90
+ </View>
91
+ <Switch
92
+ accessibilityLabel={COPY[key].label}
93
+ value={props.guidance[key] && !blocked}
94
+ disabled={blocked}
95
+ onValueChange={(value) => props.onChange(key, value)}
96
+ trackColor={styles.track}
97
+ />
98
+ </View>
99
+ );
100
+ })}
101
+ </View>
102
+ );
103
+ }
@@ -1,3 +1,4 @@
1
+ import type { RpcOutput } from "@getpaseo/plugin";
1
2
  import { usePaseo, useRpc } from "@getpaseo/plugin/client";
2
3
  import { useMutation } from "@tanstack/react-query";
3
4
  import {
@@ -7,9 +8,13 @@ import {
7
8
  updateIssueRpc,
8
9
  type WorkflowState,
9
10
  } from "../shared/linear";
11
+ import { agentHooksRpc } from "../shared/agent-hooks";
12
+ import { type Assignment, agentToolsRpc, TOOLS_SERVER } from "../shared/agent-tools";
13
+ import { launchAfterExploreRpc } from "../shared/live";
10
14
  import { AGENT_LABELS, agentTitle } from "../shared/prompts";
11
- import type { AgentAction, Isolation, LinearSettings } from "../shared/settings";
12
- import { type AgentSelection, agentConfig } from "./agent-options";
15
+ import type { AgentAction, Guidance, Isolation, LinearSettings, ToolSettings } from "../shared/settings";
16
+ import { type AgentSelection, agentConfig, optionLabel } from "./agent-options";
17
+ import { rememberLaunch } from "./agent-preferences";
13
18
  import type { PaseoProjectOption } from "./queries";
14
19
  import { errorMessage } from "./ui";
15
20
 
@@ -91,10 +96,13 @@ export function placementOptions(input: {
91
96
  return options;
92
97
  }
93
98
 
94
- // Reviews look at existing work: the pull request, then the implementing agent's workspace.
99
+ // Reviews look at existing work: the pull request, the implementing agent's workspace, then
100
+ // the issue branch. Checking out a branch nobody pushed yet starts a new worktree instead.
95
101
  function preferredPlacement(action: AgentAction, isolation: Isolation): Placement[] {
96
- if (action === "review") return ["pull-request", "agent-workspace", "workspace"];
97
- return isolation === "worktree" ? ["worktree", "workspace"] : ["workspace"];
102
+ const review = action === "review";
103
+ const existing: Placement[] = review ? ["pull-request", "agent-workspace"] : [];
104
+ const fresh: Placement[] = isolation === "worktree" ? [review ? "branch" : "worktree"] : [];
105
+ return [...existing, ...fresh, "workspace"];
98
106
  }
99
107
 
100
108
  export function resolvePlacement(
@@ -137,23 +145,80 @@ export interface LaunchRequest {
137
145
  started: WorkflowState | null;
138
146
  /** The Paseo project whose Linear key loaded the issue. */
139
147
  keyScope: string | null;
148
+ /** Maps the files with the read-only explore agent first, then starts the agent. */
149
+ explore: boolean;
150
+ /** Guidance options; for Claude some also add hooks. */
151
+ guidance: Guidance;
152
+ /** The tool settings of the project. */
153
+ tools: ToolSettings;
154
+ /** Agents the user chose for sub-issues. */
155
+ assignments: readonly SubIssueAgent[];
140
156
  }
141
157
 
142
- type Paseo = ReturnType<typeof usePaseo>;
158
+ /** The agent the user chose for one sub-issue on the launch page. */
159
+ export interface SubIssueAgent {
160
+ identifier: string;
161
+ agent: AgentSelection;
162
+ }
163
+
164
+ /** The agent's name in prompts and replies, such as "Codex · GPT-5.5 · High". */
165
+ export function agentLabel(selection: AgentSelection): string {
166
+ const parts = [selection.agent.label, selection.model.label];
167
+ if (selection.effort) parts.push(optionLabel(selection.effort));
168
+ return parts.join(" · ");
169
+ }
170
+
171
+ /** The assignments as the MCP server keeps them, by issue key. */
172
+ export function assignmentRecord(assignments: readonly SubIssueAgent[]): Record<string, Assignment> {
173
+ return Object.fromEntries(
174
+ assignments.map((entry) => [entry.identifier, { config: agentConfig(entry.agent), label: agentLabel(entry.agent) }]),
175
+ );
176
+ }
177
+
178
+ type AgentTools = RpcOutput<typeof agentToolsRpc>;
179
+
180
+ /** The config with the plugin's MCP server, whose token only reaches this issue tree. */
181
+ export function withLinearTools<C extends object>(config: C, tools: Pick<AgentTools, "server" | "toolPolicy"> | null) {
182
+ if (!tools) return config;
183
+ return { ...config, mcpServers: { [TOOLS_SERVER]: tools.server }, toolPolicy: tools.toolPolicy };
184
+ }
185
+
186
+ /** The launch needs the MCP tools for Linear edits or agents on sub-issues, when they are on. */
187
+ export const needsLinearTools = (guidance: Guidance, tools: ToolSettings, assignments: number) =>
188
+ tools.enabled && (guidance.updateLinear || guidance.paseoSubagents || assignments > 0);
189
+
190
+ /** The config with the issue's Claude Code hooks, loaded as a plugin with --plugin-dir. */
191
+ export function withHookPlugin<C extends object>(config: C, pluginDir: string | null) {
192
+ if (!pluginDir) return config;
193
+ return { ...config, providerOptions: { extraArgs: { "plugin-dir": pluginDir } } };
194
+ }
195
+
196
+ export type Paseo = ReturnType<typeof usePaseo>;
197
+
198
+ // Linear suggests a branch name for every issue, even when nobody has pushed that branch.
199
+ // The plugin API passes only the daemon's message, so match its "Unknown branch" text.
200
+ function isUnknownBranch(error: unknown): boolean {
201
+ return errorMessage(error).includes("Unknown branch");
202
+ }
203
+
204
+ function createIssueWorktree(paseo: Paseo, request: LaunchRequest, title: string) {
205
+ const { issue, project } = request;
206
+ return paseo.workspaces.create({
207
+ title,
208
+ source: {
209
+ kind: "worktree",
210
+ cwd: project.rootPath,
211
+ projectId: project.projectId,
212
+ action: "branch-off",
213
+ branchName: issue.branchName,
214
+ },
215
+ });
216
+ }
143
217
 
144
- async function openWorkspace(paseo: Paseo, request: LaunchRequest) {
218
+ export async function openWorkspace(paseo: Paseo, request: LaunchRequest) {
145
219
  const { issue, project } = request;
146
220
  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
- });
221
+ return createIssueWorktree(paseo, request, `${issue.identifier} ${issue.title}`);
157
222
  }
158
223
  if (request.placement === "pull-request" && request.prNumber !== null) {
159
224
  return paseo.workspaces.create({
@@ -168,16 +233,22 @@ async function openWorkspace(paseo: Paseo, request: LaunchRequest) {
168
233
  });
169
234
  }
170
235
  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
- });
236
+ const title = `Review ${issue.identifier}`;
237
+ try {
238
+ return await paseo.workspaces.create({
239
+ title,
240
+ source: {
241
+ kind: "worktree",
242
+ cwd: project.rootPath,
243
+ projectId: project.projectId,
244
+ action: "checkout",
245
+ refName: issue.branchName,
246
+ },
247
+ });
248
+ } catch (error) {
249
+ if (!isUnknownBranch(error)) throw error;
250
+ return createIssueWorktree(paseo, request, title);
251
+ }
181
252
  }
182
253
  if (request.placement === "agent-workspace" && request.agentWorkspace) {
183
254
  return paseo.workspaces.ref(request.agentWorkspace.id);
@@ -192,17 +263,80 @@ export function useLaunchAgent() {
192
263
  const paseo = usePaseo();
193
264
  const updateIssue = useRpc(updateIssueRpc);
194
265
  const attachCard = useRpc(attachIssueCardRpc);
266
+ const launchAfterExplore = useRpc(launchAfterExploreRpc);
267
+ const prepareHooks = useRpc(agentHooksRpc);
268
+ const prepareTools = useRpc(agentToolsRpc);
195
269
  return useMutation({
196
270
  mutationFn: async (request: LaunchRequest) => {
197
271
  const { issue, action } = request;
198
272
  const workspace = await openWorkspace(paseo, request);
199
- const agent = await workspace.agents.create({
200
- config: agentConfig(request.agent),
273
+ const state = request.patch.stateId && request.started ? request.started : issue.state;
274
+ const card = {
275
+ identifier: issue.identifier,
276
+ title: issue.title,
277
+ url: issue.url,
278
+ stateName: state.name,
279
+ stateColor: state.color,
280
+ action,
281
+ };
282
+ // Hooks are a Claude Code feature; a failure to write them never blocks the launch.
283
+ const hooks =
284
+ request.agent.agent.id === "claude"
285
+ ? await prepareHooks({
286
+ issue: {
287
+ identifier: issue.identifier,
288
+ title: issue.title,
289
+ children: issue.children.map(({ identifier, title }) => ({ identifier, title })),
290
+ },
291
+ guidance: request.guidance,
292
+ }).catch(() => null)
293
+ : null;
294
+ const config = agentConfig(request.agent);
295
+ // Without the tools the agent still starts; its prompt then names tools it does not have.
296
+ const tools = needsLinearTools(request.guidance, request.tools, request.assignments.length)
297
+ ? await prepareTools({
298
+ root: issue.identifier,
299
+ home: issue.identifier,
300
+ keyScope: request.keyScope,
301
+ workspaceId: workspace.id,
302
+ projectId: request.project.projectId,
303
+ config,
304
+ ...(request.assignments.length > 0 ? { assignments: assignmentRecord(request.assignments) } : {}),
305
+ }).catch(() => null)
306
+ : null;
307
+ const agent = {
308
+ config: withLinearTools(withHookPlugin(config, hooks?.pluginDir ?? null), tools),
201
309
  title: agentTitle(action, issue),
202
310
  prompt: request.prompt,
203
- labels: { [AGENT_LABELS.issue]: issue.identifier, [AGENT_LABELS.action]: action },
204
- });
311
+ labels: {
312
+ [AGENT_LABELS.issue]: issue.identifier,
313
+ [AGENT_LABELS.action]: action,
314
+ ...(request.keyScope ? { [AGENT_LABELS.project]: request.keyScope } : {}),
315
+ ...(tools ? { [AGENT_LABELS.tools]: tools.grantId } : {}),
316
+ },
317
+ };
318
+ // The daemon starts the agent once the map is ready, so agentId is null until then.
319
+ let agentId: string | null;
320
+ if (request.explore && action === "implement") {
321
+ const launched = await launchAfterExplore({
322
+ workspaceId: workspace.id,
323
+ identifier: issue.identifier,
324
+ keyScope: request.keyScope,
325
+ agent,
326
+ card,
327
+ });
328
+ agentId = launched.agentId;
329
+ } else {
330
+ agentId = (await workspace.agents.create(agent)).id;
331
+ void attachCard({ agentId, card }).catch(() => undefined);
332
+ }
205
333
  lastProjectByTeam.set(issue.team.id, request.project.projectId);
334
+ rememberLaunch({
335
+ agent: request.agent.agent.id,
336
+ model: request.agent.model.id,
337
+ effort: request.agent.effort?.id ?? null,
338
+ mode: request.agent.mode?.id ?? null,
339
+ });
206
340
  let warning: string | null = null;
207
341
  if (Object.keys(request.patch).length > 0) {
208
342
  try {
@@ -211,17 +345,7 @@ export function useLaunchAgent() {
211
345
  warning = errorMessage(error);
212
346
  }
213
347
  }
214
- const state = request.patch.stateId && request.started ? request.started : issue.state;
215
- const card = {
216
- identifier: issue.identifier,
217
- title: issue.title,
218
- url: issue.url,
219
- stateName: state.name,
220
- stateColor: state.color,
221
- action,
222
- };
223
- void attachCard({ agentId: agent.id, card }).catch(() => undefined);
224
- return { agentId: agent.id, warning };
348
+ return { agentId, workspaceId: workspace.id, warning };
225
349
  },
226
350
  });
227
351
  }