@yannelli/paseo-linear-plugin 1.0.1 → 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 +134 -8
- package/client/access.tsx +67 -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 +21 -6
- package/client/launch-guidance.tsx +103 -0
- package/client/launch-plan.ts +114 -17
- 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
|
@@ -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,7 +2,7 @@ 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
7
|
import { loadAgentPreferences } from "./agent-preferences";
|
|
8
8
|
import {
|
|
@@ -41,7 +41,15 @@ export function useLaunchChoices(input: {
|
|
|
41
41
|
target: TargetWorkspace | null;
|
|
42
42
|
}) {
|
|
43
43
|
const { issue, settings, action } = input;
|
|
44
|
-
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
|
+
);
|
|
45
53
|
const providers = useProviders();
|
|
46
54
|
const { target } = input;
|
|
47
55
|
const prNumber = action === "review" ? pullRequestNumber(issue) : null;
|
|
@@ -54,10 +62,14 @@ export function useLaunchChoices(input: {
|
|
|
54
62
|
const [placementChoice, setPlacementChoice] = useState<Placement | null>(null);
|
|
55
63
|
const [picks, setPicks] = useState<AgentPicks>(NO_PICKS);
|
|
56
64
|
const [remembered] = useState(loadAgentPreferences);
|
|
57
|
-
const
|
|
65
|
+
const preferredId =
|
|
58
66
|
projectChoice ??
|
|
59
67
|
defaultProjectId({ target, teamId: issue.team.id, settings, projects: projects.data });
|
|
60
|
-
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);
|
|
61
73
|
const placements = useMemo(
|
|
62
74
|
() =>
|
|
63
75
|
placementOptions({
|
|
@@ -74,10 +86,10 @@ export function useLaunchChoices(input: {
|
|
|
74
86
|
placements,
|
|
75
87
|
placementChoice,
|
|
76
88
|
action,
|
|
77
|
-
|
|
89
|
+
defaults.launch.isolation,
|
|
78
90
|
);
|
|
79
91
|
const agents = providers.data ?? [];
|
|
80
|
-
const agent = resolveAgent(agents, picks,
|
|
92
|
+
const agent = resolveAgent(agents, picks, defaults.launch.provider, remembered);
|
|
81
93
|
const chooseProject = useCallback((value: string) => {
|
|
82
94
|
setProjectChoice(value);
|
|
83
95
|
setPlacementChoice(null);
|
|
@@ -114,6 +126,9 @@ export function useLaunchChoices(input: {
|
|
|
114
126
|
prNumber,
|
|
115
127
|
project,
|
|
116
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,
|
|
117
132
|
placements,
|
|
118
133
|
placement,
|
|
119
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,12 @@ 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";
|
|
13
17
|
import { rememberLaunch } from "./agent-preferences";
|
|
14
18
|
import type { PaseoProjectOption } from "./queries";
|
|
15
19
|
import { errorMessage } from "./ui";
|
|
@@ -141,6 +145,52 @@ export interface LaunchRequest {
|
|
|
141
145
|
started: WorkflowState | null;
|
|
142
146
|
/** The Paseo project whose Linear key loaded the issue. */
|
|
143
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[];
|
|
156
|
+
}
|
|
157
|
+
|
|
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 } } };
|
|
144
194
|
}
|
|
145
195
|
|
|
146
196
|
export type Paseo = ReturnType<typeof usePaseo>;
|
|
@@ -213,16 +263,73 @@ export function useLaunchAgent() {
|
|
|
213
263
|
const paseo = usePaseo();
|
|
214
264
|
const updateIssue = useRpc(updateIssueRpc);
|
|
215
265
|
const attachCard = useRpc(attachIssueCardRpc);
|
|
266
|
+
const launchAfterExplore = useRpc(launchAfterExploreRpc);
|
|
267
|
+
const prepareHooks = useRpc(agentHooksRpc);
|
|
268
|
+
const prepareTools = useRpc(agentToolsRpc);
|
|
216
269
|
return useMutation({
|
|
217
270
|
mutationFn: async (request: LaunchRequest) => {
|
|
218
271
|
const { issue, action } = request;
|
|
219
272
|
const workspace = await openWorkspace(paseo, request);
|
|
220
|
-
const
|
|
221
|
-
|
|
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),
|
|
222
309
|
title: agentTitle(action, issue),
|
|
223
310
|
prompt: request.prompt,
|
|
224
|
-
labels: {
|
|
225
|
-
|
|
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
|
+
}
|
|
226
333
|
lastProjectByTeam.set(issue.team.id, request.project.projectId);
|
|
227
334
|
rememberLaunch({
|
|
228
335
|
agent: request.agent.agent.id,
|
|
@@ -238,17 +345,7 @@ export function useLaunchAgent() {
|
|
|
238
345
|
warning = errorMessage(error);
|
|
239
346
|
}
|
|
240
347
|
}
|
|
241
|
-
|
|
242
|
-
const card = {
|
|
243
|
-
identifier: issue.identifier,
|
|
244
|
-
title: issue.title,
|
|
245
|
-
url: issue.url,
|
|
246
|
-
stateName: state.name,
|
|
247
|
-
stateColor: state.color,
|
|
248
|
-
action,
|
|
249
|
-
};
|
|
250
|
-
void attachCard({ agentId: agent.id, card }).catch(() => undefined);
|
|
251
|
-
return { agentId: agent.id, warning };
|
|
348
|
+
return { agentId, workspaceId: workspace.id, warning };
|
|
252
349
|
},
|
|
253
350
|
});
|
|
254
351
|
}
|
package/client/launch.tsx
CHANGED
|
@@ -13,9 +13,11 @@ import {
|
|
|
13
13
|
} from "../shared/settings";
|
|
14
14
|
import { modeIcon, optionLabel } from "./agent-options";
|
|
15
15
|
import { useKeyScope } from "./key-scope";
|
|
16
|
+
import { promptAssignments, SubIssueAgents, useSubIssueAgents } from "./launch-assignments";
|
|
16
17
|
import { type LaunchChoices, useLaunchChoices } from "./launch-choices";
|
|
17
18
|
import { Chip, Field, Toggle } from "./launch-fields";
|
|
18
|
-
import {
|
|
19
|
+
import { GuidanceToggles, useProjectGuidance } from "./launch-guidance";
|
|
20
|
+
import { linearPatch, type SubIssueAgent, type TargetWorkspace, useLaunchAgent } from "./launch-plan";
|
|
19
21
|
import { ModelBrowser } from "./model-browser";
|
|
20
22
|
import { PickerModal } from "./pickers";
|
|
21
23
|
import { ProviderIcon } from "./provider-icon";
|
|
@@ -42,6 +44,7 @@ type PickerKind = "prompt" | "model" | "effort" | "mode" | "project" | "placemen
|
|
|
42
44
|
type ListPickerKind = Exclude<PickerKind, "model">;
|
|
43
45
|
|
|
44
46
|
const SCROLL_STYLE = { flex: 1 } as const;
|
|
47
|
+
const NO_ASSIGNMENTS: readonly SubIssueAgent[] = [];
|
|
45
48
|
const PROMPT_CHOICES = AGENT_ACTIONS.map((action) => ({
|
|
46
49
|
value: action,
|
|
47
50
|
label: ACTION_LABELS[action].title,
|
|
@@ -150,13 +153,23 @@ function LaunchComposer(props: LaunchPageProps & { issue: IssueDetail; settings:
|
|
|
150
153
|
?.states.find((state) => state.type === "started") ?? null;
|
|
151
154
|
const canStart = action === "implement" && !["started", "completed"].includes(issue.state.type);
|
|
152
155
|
const canAssign = action === "implement" && viewerId !== null && issue.assignee?.id !== viewerId;
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
const [
|
|
156
|
+
// Null until the user flips the option. Until then it follows the chosen project.
|
|
157
|
+
const { defaults } = choices;
|
|
158
|
+
const [commentsChoice, setIncludeComments] = useState<boolean | null>(null);
|
|
159
|
+
const [startedChoice, setMoveToStarted] = useState<boolean | null>(null);
|
|
160
|
+
const [assignChoice, setAssignToMe] = useState<boolean | null>(null);
|
|
161
|
+
const includeComments = commentsChoice ?? defaults.launch.includeComments;
|
|
162
|
+
const moveToStarted = startedChoice ?? defaults.launch.moveToStarted;
|
|
163
|
+
const assignToMe = assignChoice ?? defaults.launch.assignToMe;
|
|
156
164
|
const [picker, setPicker] = useState<PickerKind | null>(null);
|
|
157
165
|
// Null until the user types. Until then the prompt follows the template and options.
|
|
158
166
|
const [customPrompt, setCustomPrompt] = useState<string | null>(null);
|
|
159
167
|
const { projectConfig } = choices;
|
|
168
|
+
const { guidance, change: changeGuidance } = useProjectGuidance(choices.project, projectConfig);
|
|
169
|
+
const { tools } = defaults;
|
|
170
|
+
const subAgents = useSubIssueAgents(issue, choices);
|
|
171
|
+
const assigning = action === "implement" && tools.assignAgents && issue.children.length > 0;
|
|
172
|
+
const assignments = assigning && tools.enabled ? subAgents.assignments : NO_ASSIGNMENTS;
|
|
160
173
|
const generated = useMemo(
|
|
161
174
|
() =>
|
|
162
175
|
composePrompt({
|
|
@@ -166,8 +179,10 @@ function LaunchComposer(props: LaunchPageProps & { issue: IssueDetail; settings:
|
|
|
166
179
|
project: projectConfig,
|
|
167
180
|
includeComments,
|
|
168
181
|
extraInstructions: "",
|
|
182
|
+
guidance,
|
|
183
|
+
tools: { ...tools, assignments: promptAssignments(assignments) },
|
|
169
184
|
}),
|
|
170
|
-
[action, issue, settings, projectConfig, includeComments],
|
|
185
|
+
[action, issue, settings, projectConfig, includeComments, guidance, tools, assignments],
|
|
171
186
|
);
|
|
172
187
|
const prompt = customPrompt ?? generated;
|
|
173
188
|
const styles = useMemo(() => {
|
|
@@ -271,15 +286,23 @@ function LaunchComposer(props: LaunchPageProps & { issue: IssueDetail; settings:
|
|
|
271
286
|
patch,
|
|
272
287
|
started,
|
|
273
288
|
keyScope,
|
|
289
|
+
guidance,
|
|
290
|
+
tools,
|
|
291
|
+
assignments,
|
|
292
|
+
explore: settings.live.enabled && settings.live.mapping === "explore",
|
|
274
293
|
},
|
|
275
294
|
{
|
|
276
|
-
onSuccess: ({ agentId, warning }) => {
|
|
295
|
+
onSuccess: ({ agentId, workspaceId, warning }) => {
|
|
277
296
|
void queries.invalidateQueries({ queryKey: ["linear"] });
|
|
297
|
+
const started = agentId
|
|
298
|
+
? "Agent started."
|
|
299
|
+
: "Mapping files first. The agent starts when the map is ready.";
|
|
278
300
|
if (warning) {
|
|
279
|
-
toast.show(
|
|
280
|
-
} else toast.show(
|
|
301
|
+
toast.show(`${started} Linear was not updated: ${warning}`, { variant: "warning" });
|
|
302
|
+
} else toast.show(started, { variant: "success" });
|
|
281
303
|
onStarted();
|
|
282
|
-
navigation?.openAgent({ agentId });
|
|
304
|
+
if (agentId) navigation?.openAgent({ agentId });
|
|
305
|
+
else navigation?.openWorkspace({ workspaceId });
|
|
283
306
|
},
|
|
284
307
|
},
|
|
285
308
|
);
|
|
@@ -296,6 +319,9 @@ function LaunchComposer(props: LaunchPageProps & { issue: IssueDetail; settings:
|
|
|
296
319
|
issue,
|
|
297
320
|
prompt,
|
|
298
321
|
keyScope,
|
|
322
|
+
guidance,
|
|
323
|
+
tools,
|
|
324
|
+
assignments,
|
|
299
325
|
queries,
|
|
300
326
|
toast,
|
|
301
327
|
onStarted,
|
|
@@ -438,6 +464,26 @@ function LaunchComposer(props: LaunchPageProps & { issue: IssueDetail; settings:
|
|
|
438
464
|
/>
|
|
439
465
|
) : null}
|
|
440
466
|
</View>
|
|
467
|
+
<View style={styles.group}>
|
|
468
|
+
<SectionLabel theme={theme}>Agent guidance</SectionLabel>
|
|
469
|
+
<GuidanceToggles theme={theme} guidance={guidance} tools={tools} onChange={changeGuidance} />
|
|
470
|
+
</View>
|
|
471
|
+
{assigning ? (
|
|
472
|
+
<View style={styles.group}>
|
|
473
|
+
<SectionLabel theme={theme}>Sub-issue agents</SectionLabel>
|
|
474
|
+
<SubIssueAgents
|
|
475
|
+
theme={theme}
|
|
476
|
+
issue={issue}
|
|
477
|
+
choices={choices}
|
|
478
|
+
state={subAgents}
|
|
479
|
+
blocked={
|
|
480
|
+
tools.enabled
|
|
481
|
+
? null
|
|
482
|
+
: "Sub-issue agents need the plugin's Linear tools, which are off for this project. Turn them on in Linear settings, under Agents."
|
|
483
|
+
}
|
|
484
|
+
/>
|
|
485
|
+
</View>
|
|
486
|
+
) : null}
|
|
441
487
|
{launch.error ? (
|
|
442
488
|
<Text accessibilityRole="alert" style={styles.error}>
|
|
443
489
|
{errorMessage(launch.error)}
|