@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.
- package/OVERVIEW.md +32 -2
- package/README.md +138 -11
- package/client/access.tsx +67 -0
- package/client/agent-options.ts +12 -2
- package/client/agent-preferences.ts +112 -0
- package/client/browser.tsx +12 -9
- package/client/compat.ts +23 -5
- package/client/connect.tsx +3 -2
- package/client/launch-assignments.tsx +182 -0
- package/client/launch-choices.ts +23 -6
- package/client/launch-guidance.tsx +103 -0
- package/client/launch-plan.ts +166 -42
- package/client/launch.tsx +55 -9
- package/client/live-agents.tsx +446 -0
- package/client/live-feed.tsx +151 -0
- package/client/live-header.tsx +310 -0
- package/client/live-issues.tsx +345 -0
- package/client/live-map.tsx +351 -0
- package/client/live-panel.tsx +392 -0
- package/client/live-timeline.ts +379 -0
- package/client/pill.tsx +394 -0
- package/client/plugin-icon.tsx +75 -0
- package/client/project-init.tsx +192 -0
- package/client/project-knowledge.tsx +87 -0
- package/client/settings-access.tsx +56 -0
- package/client/settings-agents.tsx +113 -0
- package/client/settings-fields.tsx +11 -3
- package/client/settings-icon.tsx +277 -0
- package/client/settings-live.tsx +128 -0
- package/client/settings-overrides.tsx +148 -0
- package/client/settings-projects.tsx +26 -4
- package/client/settings-prompts.tsx +3 -3
- package/client/timeline-card.tsx +2 -1
- package/client/tool-buttons.tsx +68 -0
- package/client/ui.tsx +16 -7
- package/client/web.ts +81 -0
- package/index.client.tsx +160 -51
- package/index.server.ts +32 -5
- package/package.json +1 -1
- package/server/agent-hooks.ts +37 -0
- package/server/agent-runs.ts +312 -0
- package/server/agent-tools.ts +276 -0
- package/server/handlers.ts +12 -1
- package/server/init.ts +89 -0
- package/server/inspect.ts +392 -0
- package/server/knowledge.ts +157 -0
- package/server/live.ts +371 -0
- package/server/mcp-http.ts +156 -0
- package/server/stores.ts +126 -0
- package/server/subagent-logs.ts +146 -0
- package/server/sync.ts +133 -0
- package/shared/activity.ts +268 -0
- package/shared/agent-hooks.ts +68 -0
- package/shared/agent-tools.ts +157 -0
- package/shared/custom-icon.ts +188 -0
- package/shared/knowledge.ts +255 -0
- package/shared/live.ts +248 -0
- package/shared/map-model.ts +240 -0
- package/shared/project-setup.ts +56 -0
- package/shared/prompts.ts +109 -2
- package/shared/settings.ts +145 -3
- package/shared/shell-activity.ts +309 -0
- package/shared/shell-lexer.ts +180 -0
- package/shared/subagents.ts +106 -0
- package/shared/todo-sync.ts +158 -0
package/client/connect.tsx
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { useRpc } from "@getpaseo/plugin/client";
|
|
2
|
-
import {
|
|
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
|
-
<
|
|
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
|
+
}
|
package/client/launch-choices.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
89
|
+
defaults.launch.isolation,
|
|
76
90
|
);
|
|
77
91
|
const agents = providers.data ?? [];
|
|
78
|
-
const agent = resolveAgent(agents, picks,
|
|
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
|
+
}
|
package/client/launch-plan.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
200
|
-
|
|
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: {
|
|
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
|
-
|
|
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
|
}
|