@yannelli/paseo-linear-plugin 1.0.1 → 1.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/OVERVIEW.md +20 -2
- package/README.md +91 -5
- package/client/access.tsx +66 -0
- package/client/browser.tsx +12 -9
- package/client/graph-camera.tsx +219 -0
- package/client/graph-labels.ts +147 -0
- package/client/launch-choices.ts +15 -4
- package/client/launch-guidance.tsx +86 -0
- package/client/launch-plan.ts +86 -16
- package/client/launch.tsx +19 -5
- package/client/live-agents.tsx +522 -0
- package/client/live-cursor.tsx +293 -0
- package/client/live-feed.tsx +151 -0
- package/client/live-graph.tsx +518 -0
- package/client/live-header.tsx +341 -0
- package/client/live-issues.tsx +345 -0
- package/client/live-map.tsx +345 -0
- package/client/live-panel.tsx +465 -0
- package/client/live-timeline.ts +376 -0
- package/client/pill.tsx +393 -0
- package/client/project-init.tsx +168 -0
- package/client/settings-access.tsx +56 -0
- package/client/settings-live.tsx +145 -0
- package/client/settings-projects.tsx +9 -0
- package/client/tool-buttons.tsx +68 -0
- package/client/web.ts +45 -0
- package/index.client.tsx +39 -1
- package/index.server.ts +29 -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 +240 -0
- package/server/handlers.ts +12 -1
- package/server/init.ts +65 -0
- package/server/links.ts +153 -0
- package/server/live.ts +360 -0
- package/server/mcp-http.ts +152 -0
- package/server/stores.ts +126 -0
- package/server/subagent-logs.ts +146 -0
- package/server/sync.ts +127 -0
- package/shared/activity.ts +312 -0
- package/shared/agent-hooks.ts +68 -0
- package/shared/agent-tools.ts +130 -0
- package/shared/graph-camera.ts +93 -0
- package/shared/graph-geometry.ts +122 -0
- package/shared/graph-model.ts +319 -0
- package/shared/links.ts +252 -0
- package/shared/live.ts +243 -0
- package/shared/map-model.ts +192 -0
- package/shared/project-setup.ts +55 -0
- package/shared/prompts.ts +65 -2
- package/shared/settings.ts +88 -1
- 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,147 @@
|
|
|
1
|
+
import { type LabelRequest, labelBox, labelSides, type Placed, type Point, placeLabels } from "../shared/graph-geometry";
|
|
2
|
+
import { folderCenters, type Graph, type GraphNode } from "../shared/graph-model";
|
|
3
|
+
import type { IssueDetail } from "../shared/linear";
|
|
4
|
+
import { todoStatus } from "../shared/todo-sync";
|
|
5
|
+
import type { IssueProgress } from "./live-issues";
|
|
6
|
+
|
|
7
|
+
// Which labels the graph shows and where: issues first, then folder names, then the files the
|
|
8
|
+
// agent works on, then touched files. A label that fits nowhere is hidden.
|
|
9
|
+
|
|
10
|
+
const SIZES = { issue: 46, subissue: 32 } as const;
|
|
11
|
+
export const HOVER_GAP = 22;
|
|
12
|
+
|
|
13
|
+
/** About how wide a cursor caption draws. */
|
|
14
|
+
export const captionWidth = (text: string | null) => (text ? Math.min(190, text.length * 6.2 + 18) : 0);
|
|
15
|
+
const MAX_LABEL = 170;
|
|
16
|
+
|
|
17
|
+
export function nodeSize(node: GraphNode): number {
|
|
18
|
+
if (node.kind !== "file") return SIZES[node.kind];
|
|
19
|
+
const touched = node.tile && node.tile.touch !== "none";
|
|
20
|
+
return touched ? (node.tile?.touch === "read" ? 12 : 14) : 9;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function clip(text: string, max: number): string {
|
|
24
|
+
return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface Label {
|
|
28
|
+
left: number;
|
|
29
|
+
top: number;
|
|
30
|
+
width: number;
|
|
31
|
+
lines: readonly [string, string | null];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface FolderLabel {
|
|
35
|
+
folder: string;
|
|
36
|
+
left: number;
|
|
37
|
+
top: number;
|
|
38
|
+
width: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface LabelInput {
|
|
42
|
+
graph: Graph;
|
|
43
|
+
positions: ReadonlyMap<string, Placed>;
|
|
44
|
+
issue: IssueDetail;
|
|
45
|
+
progress: IssueProgress;
|
|
46
|
+
selected: string | null;
|
|
47
|
+
/** Nodes a running agent works on. */
|
|
48
|
+
working: ReadonlySet<string>;
|
|
49
|
+
/** Where each agent hovers, with the width of its caption and the side it shows on. */
|
|
50
|
+
cursors: readonly { at: Point; caption: number; flip: boolean }[];
|
|
51
|
+
width: number;
|
|
52
|
+
height: number;
|
|
53
|
+
/** Zoomed in, so files no agent touched get labels too when they fit. */
|
|
54
|
+
every?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function linesOf(node: GraphNode, input: LabelInput): readonly [string, string | null] {
|
|
58
|
+
if (node.kind === "issue") return [node.label, clip(input.issue.title, 34)];
|
|
59
|
+
if (node.kind === "file") return [node.label, null];
|
|
60
|
+
const child = input.issue.children.find((entry) => entry.identifier.toUpperCase() === node.label);
|
|
61
|
+
const todos = input.progress.groups.get(node.label) ?? [];
|
|
62
|
+
const done = todos.filter((todo) => todoStatus(todo) === "completed").length;
|
|
63
|
+
const counts = todos.length > 0 ? ` ${done}/${todos.length}` : "";
|
|
64
|
+
const folded = node.folded > 0 ? ` +${node.folded}` : "";
|
|
65
|
+
return [`${node.label}${counts}${folded}`, clip(child?.title ?? "", 26)];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const textWidth = (text: string, perChar: number) => Math.min(MAX_LABEL, text.length * perChar + 4);
|
|
69
|
+
|
|
70
|
+
export function graphLabels(input: LabelInput): { nodes: Map<string, Label>; folders: FolderLabel[] } {
|
|
71
|
+
const { graph, positions, selected, working } = input;
|
|
72
|
+
const order = (node: GraphNode) =>
|
|
73
|
+
node.id === selected
|
|
74
|
+
? 0
|
|
75
|
+
: node.kind === "issue"
|
|
76
|
+
? 1
|
|
77
|
+
: node.kind === "subissue"
|
|
78
|
+
? 2
|
|
79
|
+
: working.has(node.id)
|
|
80
|
+
? 4
|
|
81
|
+
: (node.tile?.touch ?? "none") !== "none"
|
|
82
|
+
? 5
|
|
83
|
+
: 6;
|
|
84
|
+
const wanted = graph.nodes.filter((node) => input.every || order(node) < 6).sort((a, b) => order(a) - order(b));
|
|
85
|
+
const lines = new Map<string, readonly [string, string | null]>();
|
|
86
|
+
const requests: LabelRequest[] = [];
|
|
87
|
+
const push = (node: GraphNode) => {
|
|
88
|
+
const at = positions.get(node.id);
|
|
89
|
+
if (!at) return;
|
|
90
|
+
const text = linesOf(node, input);
|
|
91
|
+
const perChar = node.kind === "issue" ? 7.4 : 6.2;
|
|
92
|
+
const width = Math.max(textWidth(text[0], perChar), text[1] ? textWidth(text[1], 5.6) : 0);
|
|
93
|
+
const height = text[1] ? 28 : 14;
|
|
94
|
+
const spotsFor = (w: number, h: number) =>
|
|
95
|
+
labelSides(at.side).map((side) => labelBox({ ...at, side }, nodeSize(node) / 2, w, h));
|
|
96
|
+
const short = textWidth(text[0], perChar);
|
|
97
|
+
lines.set(node.id, text);
|
|
98
|
+
requests.push({
|
|
99
|
+
id: node.id,
|
|
100
|
+
width,
|
|
101
|
+
height,
|
|
102
|
+
spots: spotsFor(width, height),
|
|
103
|
+
fallback: text[1] ? { width: short, height: 14, spots: spotsFor(short, 14) } : undefined,
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
for (const node of wanted.filter((entry) => order(entry) <= 2)) push(node);
|
|
107
|
+
// Folder names sit above each neighborhood, after the issues and before single files.
|
|
108
|
+
const folderIds = new Map<string, string>();
|
|
109
|
+
for (const center of folderCenters(graph, positions)) {
|
|
110
|
+
const name = center.folder === "." ? "/" : `${center.folder}/`;
|
|
111
|
+
const width = textWidth(name, 6);
|
|
112
|
+
const id = `folder:${center.folder}`;
|
|
113
|
+
folderIds.set(id, center.folder);
|
|
114
|
+
requests.push({ id, width, height: 14, spots: [{ left: center.x - width / 2, top: center.top - 30 }, { left: center.x - width / 2, top: center.y - 7 }] });
|
|
115
|
+
}
|
|
116
|
+
for (const node of wanted.filter((entry) => order(entry) > 2)) push(node);
|
|
117
|
+
const shapes: { at: Point; radius: number }[] = graph.nodes.flatMap((node) => {
|
|
118
|
+
const at = positions.get(node.id);
|
|
119
|
+
return at ? [{ at, radius: nodeSize(node) / 2 }] : [];
|
|
120
|
+
});
|
|
121
|
+
// Keep labels out from under the agents and their captions, as a row of small circles.
|
|
122
|
+
for (const cursor of input.cursors) {
|
|
123
|
+
shapes.push({ at: cursor.at, radius: 16 });
|
|
124
|
+
for (let offset = 24; offset < cursor.caption + 24; offset += 14) {
|
|
125
|
+
shapes.push({ at: { x: cursor.at.x + (cursor.flip ? -offset : offset), y: cursor.at.y }, radius: 10 });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const sizes = new Map(requests.map((request) => [request.id, request]));
|
|
129
|
+
const nodes = new Map<string, Label>();
|
|
130
|
+
const folders: FolderLabel[] = [];
|
|
131
|
+
for (const [id, box] of placeLabels(requests, input.width, input.height, shapes)) {
|
|
132
|
+
const request = sizes.get(id) as LabelRequest;
|
|
133
|
+
const folder = folderIds.get(id);
|
|
134
|
+
if (folder !== undefined) {
|
|
135
|
+
folders.push({ folder, left: box.left, top: box.top, width: request.width });
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const text = lines.get(id) ?? ["", null];
|
|
139
|
+
nodes.set(id, {
|
|
140
|
+
left: box.left,
|
|
141
|
+
top: box.top,
|
|
142
|
+
width: (box.short ? request.fallback?.width : request.width) ?? 0,
|
|
143
|
+
lines: box.short ? [text[0], null] : text,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return { nodes, folders };
|
|
147
|
+
}
|
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, 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,13 @@ 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;
|
|
61
72
|
const placements = useMemo(
|
|
62
73
|
() =>
|
|
63
74
|
placementOptions({
|
|
@@ -0,0 +1,86 @@
|
|
|
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
|
+
withProjectGuidance,
|
|
11
|
+
} from "../shared/settings";
|
|
12
|
+
import type { PaseoProjectOption } from "./queries";
|
|
13
|
+
import type { Theme } from "./ui";
|
|
14
|
+
|
|
15
|
+
// Launch options that add instructions to the prompt. Each project keeps its own choices, so
|
|
16
|
+
// the next launch in the project starts with them.
|
|
17
|
+
|
|
18
|
+
const DEFAULT_GUIDANCE = GuidanceSchema.parse({});
|
|
19
|
+
|
|
20
|
+
const COPY: Record<(typeof GUIDANCE_KEYS)[number], { label: string; hint: string }> = {
|
|
21
|
+
updateLinear: {
|
|
22
|
+
label: "Keep Linear updated",
|
|
23
|
+
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.",
|
|
24
|
+
},
|
|
25
|
+
subagentKeys: {
|
|
26
|
+
label: "Subagents state their issue key",
|
|
27
|
+
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.",
|
|
28
|
+
},
|
|
29
|
+
paseoSubagents: {
|
|
30
|
+
label: "Hand off work to Paseo agents",
|
|
31
|
+
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.",
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** The project's guidance, changed locally at once and saved to the project in the background. */
|
|
36
|
+
export function useProjectGuidance(project: PaseoProjectOption | null, config: ProjectConfig | null) {
|
|
37
|
+
const settings = useSettings(linearSettings);
|
|
38
|
+
const [edits, setEdits] = useState<Record<string, Guidance>>({});
|
|
39
|
+
const guidance = (project ? edits[project.projectId] : undefined) ?? config?.guidance ?? DEFAULT_GUIDANCE;
|
|
40
|
+
const change = useCallback(
|
|
41
|
+
(key: (typeof GUIDANCE_KEYS)[number], value: boolean) => {
|
|
42
|
+
if (!project) return;
|
|
43
|
+
const next = { ...guidance, [key]: value };
|
|
44
|
+
setEdits((current) => ({ ...current, [project.projectId]: next }));
|
|
45
|
+
if (settings.status === "ready") void settings.save(withProjectGuidance(settings.values, project, next), settings.revision);
|
|
46
|
+
},
|
|
47
|
+
[project, guidance, settings],
|
|
48
|
+
);
|
|
49
|
+
return { guidance, change };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function GuidanceToggles(props: {
|
|
53
|
+
theme: Theme;
|
|
54
|
+
guidance: Guidance;
|
|
55
|
+
onChange(key: (typeof GUIDANCE_KEYS)[number], value: boolean): void;
|
|
56
|
+
}) {
|
|
57
|
+
const { colors } = props.theme;
|
|
58
|
+
const styles = useMemo(
|
|
59
|
+
() => ({
|
|
60
|
+
row: { flexDirection: "row" as const, alignItems: "center" as const, gap: 10, paddingVertical: 4 },
|
|
61
|
+
body: { flex: 1, gap: 2 },
|
|
62
|
+
label: { color: colors.foreground, fontSize: 14 },
|
|
63
|
+
hint: { color: colors.foregroundMuted, fontSize: 12 },
|
|
64
|
+
track: { true: colors.accent, false: colors.surface2 },
|
|
65
|
+
}),
|
|
66
|
+
[colors],
|
|
67
|
+
);
|
|
68
|
+
return (
|
|
69
|
+
<View>
|
|
70
|
+
{GUIDANCE_KEYS.map((key) => (
|
|
71
|
+
<View key={key} style={styles.row}>
|
|
72
|
+
<View style={styles.body}>
|
|
73
|
+
<Text style={styles.label}>{COPY[key].label}</Text>
|
|
74
|
+
<Text style={styles.hint}>{COPY[key].hint}</Text>
|
|
75
|
+
</View>
|
|
76
|
+
<Switch
|
|
77
|
+
accessibilityLabel={COPY[key].label}
|
|
78
|
+
value={props.guidance[key]}
|
|
79
|
+
onValueChange={(value) => props.onChange(key, value)}
|
|
80
|
+
trackColor={styles.track}
|
|
81
|
+
/>
|
|
82
|
+
</View>
|
|
83
|
+
))}
|
|
84
|
+
</View>
|
|
85
|
+
);
|
|
86
|
+
}
|
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,8 +8,11 @@ import {
|
|
|
7
8
|
updateIssueRpc,
|
|
8
9
|
type WorkflowState,
|
|
9
10
|
} from "../shared/linear";
|
|
11
|
+
import { agentHooksRpc } from "../shared/agent-hooks";
|
|
12
|
+
import { 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";
|
|
15
|
+
import type { AgentAction, Guidance, Isolation, LinearSettings } from "../shared/settings";
|
|
12
16
|
import { type AgentSelection, agentConfig } from "./agent-options";
|
|
13
17
|
import { rememberLaunch } from "./agent-preferences";
|
|
14
18
|
import type { PaseoProjectOption } from "./queries";
|
|
@@ -141,6 +145,27 @@ 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
|
+
}
|
|
153
|
+
|
|
154
|
+
type AgentTools = RpcOutput<typeof agentToolsRpc>;
|
|
155
|
+
|
|
156
|
+
/** The config with the plugin's MCP server, whose token only reaches this issue tree. */
|
|
157
|
+
export function withLinearTools<C extends object>(config: C, tools: Pick<AgentTools, "server" | "toolPolicy"> | null) {
|
|
158
|
+
if (!tools) return config;
|
|
159
|
+
return { ...config, mcpServers: { [TOOLS_SERVER]: tools.server }, toolPolicy: tools.toolPolicy };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The issue's guidance needs the MCP tools: Linear edits, or agents started on sub-issues. */
|
|
163
|
+
export const needsLinearTools = (guidance: Guidance) => guidance.updateLinear || guidance.paseoSubagents;
|
|
164
|
+
|
|
165
|
+
/** The config with the issue's Claude Code hooks, loaded as a plugin with --plugin-dir. */
|
|
166
|
+
export function withHookPlugin<C extends object>(config: C, pluginDir: string | null) {
|
|
167
|
+
if (!pluginDir) return config;
|
|
168
|
+
return { ...config, providerOptions: { extraArgs: { "plugin-dir": pluginDir } } };
|
|
144
169
|
}
|
|
145
170
|
|
|
146
171
|
export type Paseo = ReturnType<typeof usePaseo>;
|
|
@@ -213,16 +238,71 @@ export function useLaunchAgent() {
|
|
|
213
238
|
const paseo = usePaseo();
|
|
214
239
|
const updateIssue = useRpc(updateIssueRpc);
|
|
215
240
|
const attachCard = useRpc(attachIssueCardRpc);
|
|
241
|
+
const launchAfterExplore = useRpc(launchAfterExploreRpc);
|
|
242
|
+
const prepareHooks = useRpc(agentHooksRpc);
|
|
243
|
+
const prepareTools = useRpc(agentToolsRpc);
|
|
216
244
|
return useMutation({
|
|
217
245
|
mutationFn: async (request: LaunchRequest) => {
|
|
218
246
|
const { issue, action } = request;
|
|
219
247
|
const workspace = await openWorkspace(paseo, request);
|
|
220
|
-
const
|
|
221
|
-
|
|
248
|
+
const state = request.patch.stateId && request.started ? request.started : issue.state;
|
|
249
|
+
const card = {
|
|
250
|
+
identifier: issue.identifier,
|
|
251
|
+
title: issue.title,
|
|
252
|
+
url: issue.url,
|
|
253
|
+
stateName: state.name,
|
|
254
|
+
stateColor: state.color,
|
|
255
|
+
action,
|
|
256
|
+
};
|
|
257
|
+
// Hooks are a Claude Code feature; a failure to write them never blocks the launch.
|
|
258
|
+
const hooks =
|
|
259
|
+
request.agent.agent.id === "claude"
|
|
260
|
+
? await prepareHooks({
|
|
261
|
+
issue: {
|
|
262
|
+
identifier: issue.identifier,
|
|
263
|
+
title: issue.title,
|
|
264
|
+
children: issue.children.map(({ identifier, title }) => ({ identifier, title })),
|
|
265
|
+
},
|
|
266
|
+
guidance: request.guidance,
|
|
267
|
+
}).catch(() => null)
|
|
268
|
+
: null;
|
|
269
|
+
const config = agentConfig(request.agent);
|
|
270
|
+
// Without the tools the agent still starts; its prompt then names tools it does not have.
|
|
271
|
+
const tools = needsLinearTools(request.guidance)
|
|
272
|
+
? await prepareTools({
|
|
273
|
+
root: issue.identifier,
|
|
274
|
+
home: issue.identifier,
|
|
275
|
+
keyScope: request.keyScope,
|
|
276
|
+
workspaceId: workspace.id,
|
|
277
|
+
config,
|
|
278
|
+
}).catch(() => null)
|
|
279
|
+
: null;
|
|
280
|
+
const agent = {
|
|
281
|
+
config: withLinearTools(withHookPlugin(config, hooks?.pluginDir ?? null), tools),
|
|
222
282
|
title: agentTitle(action, issue),
|
|
223
283
|
prompt: request.prompt,
|
|
224
|
-
labels: {
|
|
225
|
-
|
|
284
|
+
labels: {
|
|
285
|
+
[AGENT_LABELS.issue]: issue.identifier,
|
|
286
|
+
[AGENT_LABELS.action]: action,
|
|
287
|
+
...(request.keyScope ? { [AGENT_LABELS.project]: request.keyScope } : {}),
|
|
288
|
+
...(tools ? { [AGENT_LABELS.tools]: tools.grantId } : {}),
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
// The daemon starts the agent once the map is ready, so agentId is null until then.
|
|
292
|
+
let agentId: string | null;
|
|
293
|
+
if (request.explore && action === "implement") {
|
|
294
|
+
const launched = await launchAfterExplore({
|
|
295
|
+
workspaceId: workspace.id,
|
|
296
|
+
identifier: issue.identifier,
|
|
297
|
+
keyScope: request.keyScope,
|
|
298
|
+
agent,
|
|
299
|
+
card,
|
|
300
|
+
});
|
|
301
|
+
agentId = launched.agentId;
|
|
302
|
+
} else {
|
|
303
|
+
agentId = (await workspace.agents.create(agent)).id;
|
|
304
|
+
void attachCard({ agentId, card }).catch(() => undefined);
|
|
305
|
+
}
|
|
226
306
|
lastProjectByTeam.set(issue.team.id, request.project.projectId);
|
|
227
307
|
rememberLaunch({
|
|
228
308
|
agent: request.agent.agent.id,
|
|
@@ -238,17 +318,7 @@ export function useLaunchAgent() {
|
|
|
238
318
|
warning = errorMessage(error);
|
|
239
319
|
}
|
|
240
320
|
}
|
|
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 };
|
|
321
|
+
return { agentId, workspaceId: workspace.id, warning };
|
|
252
322
|
},
|
|
253
323
|
});
|
|
254
324
|
}
|
package/client/launch.tsx
CHANGED
|
@@ -15,6 +15,7 @@ import { modeIcon, optionLabel } from "./agent-options";
|
|
|
15
15
|
import { useKeyScope } from "./key-scope";
|
|
16
16
|
import { type LaunchChoices, useLaunchChoices } from "./launch-choices";
|
|
17
17
|
import { Chip, Field, Toggle } from "./launch-fields";
|
|
18
|
+
import { GuidanceToggles, useProjectGuidance } from "./launch-guidance";
|
|
18
19
|
import { linearPatch, type TargetWorkspace, useLaunchAgent } from "./launch-plan";
|
|
19
20
|
import { ModelBrowser } from "./model-browser";
|
|
20
21
|
import { PickerModal } from "./pickers";
|
|
@@ -157,6 +158,7 @@ function LaunchComposer(props: LaunchPageProps & { issue: IssueDetail; settings:
|
|
|
157
158
|
// Null until the user types. Until then the prompt follows the template and options.
|
|
158
159
|
const [customPrompt, setCustomPrompt] = useState<string | null>(null);
|
|
159
160
|
const { projectConfig } = choices;
|
|
161
|
+
const { guidance, change: changeGuidance } = useProjectGuidance(choices.project, projectConfig);
|
|
160
162
|
const generated = useMemo(
|
|
161
163
|
() =>
|
|
162
164
|
composePrompt({
|
|
@@ -166,8 +168,9 @@ function LaunchComposer(props: LaunchPageProps & { issue: IssueDetail; settings:
|
|
|
166
168
|
project: projectConfig,
|
|
167
169
|
includeComments,
|
|
168
170
|
extraInstructions: "",
|
|
171
|
+
guidance,
|
|
169
172
|
}),
|
|
170
|
-
[action, issue, settings, projectConfig, includeComments],
|
|
173
|
+
[action, issue, settings, projectConfig, includeComments, guidance],
|
|
171
174
|
);
|
|
172
175
|
const prompt = customPrompt ?? generated;
|
|
173
176
|
const styles = useMemo(() => {
|
|
@@ -271,15 +274,21 @@ function LaunchComposer(props: LaunchPageProps & { issue: IssueDetail; settings:
|
|
|
271
274
|
patch,
|
|
272
275
|
started,
|
|
273
276
|
keyScope,
|
|
277
|
+
guidance,
|
|
278
|
+
explore: settings.live.enabled && settings.live.mapping === "explore",
|
|
274
279
|
},
|
|
275
280
|
{
|
|
276
|
-
onSuccess: ({ agentId, warning }) => {
|
|
281
|
+
onSuccess: ({ agentId, workspaceId, warning }) => {
|
|
277
282
|
void queries.invalidateQueries({ queryKey: ["linear"] });
|
|
283
|
+
const started = agentId
|
|
284
|
+
? "Agent started."
|
|
285
|
+
: "Mapping files first. The agent starts when the map is ready.";
|
|
278
286
|
if (warning) {
|
|
279
|
-
toast.show(
|
|
280
|
-
} else toast.show(
|
|
287
|
+
toast.show(`${started} Linear was not updated: ${warning}`, { variant: "warning" });
|
|
288
|
+
} else toast.show(started, { variant: "success" });
|
|
281
289
|
onStarted();
|
|
282
|
-
navigation?.openAgent({ agentId });
|
|
290
|
+
if (agentId) navigation?.openAgent({ agentId });
|
|
291
|
+
else navigation?.openWorkspace({ workspaceId });
|
|
283
292
|
},
|
|
284
293
|
},
|
|
285
294
|
);
|
|
@@ -296,6 +305,7 @@ function LaunchComposer(props: LaunchPageProps & { issue: IssueDetail; settings:
|
|
|
296
305
|
issue,
|
|
297
306
|
prompt,
|
|
298
307
|
keyScope,
|
|
308
|
+
guidance,
|
|
299
309
|
queries,
|
|
300
310
|
toast,
|
|
301
311
|
onStarted,
|
|
@@ -438,6 +448,10 @@ function LaunchComposer(props: LaunchPageProps & { issue: IssueDetail; settings:
|
|
|
438
448
|
/>
|
|
439
449
|
) : null}
|
|
440
450
|
</View>
|
|
451
|
+
<View style={styles.group}>
|
|
452
|
+
<SectionLabel theme={theme}>Agent guidance</SectionLabel>
|
|
453
|
+
<GuidanceToggles theme={theme} guidance={guidance} onChange={changeGuidance} />
|
|
454
|
+
</View>
|
|
441
455
|
{launch.error ? (
|
|
442
456
|
<Text accessibilityRole="alert" style={styles.error}>
|
|
443
457
|
{errorMessage(launch.error)}
|