@yannelli/paseo-linear-plugin 1.1.0-beta.0 → 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 +3 -3
- package/README.md +26 -10
- package/client/graph-camera.tsx +219 -0
- package/client/graph-labels.ts +14 -4
- package/client/launch-guidance.tsx +86 -0
- package/client/launch-plan.ts +50 -2
- package/client/launch.tsx +10 -1
- package/client/live-agents.tsx +138 -23
- package/client/live-cursor.tsx +3 -1
- package/client/live-graph.tsx +159 -75
- package/client/live-header.tsx +29 -0
- package/client/live-panel.tsx +29 -8
- package/client/live-timeline.ts +38 -3
- package/client/settings-live.tsx +25 -1
- package/client/tool-buttons.tsx +68 -0
- package/client/web.ts +45 -0
- package/index.server.ts +5 -1
- package/package.json +1 -1
- package/server/agent-hooks.ts +37 -0
- package/server/agent-runs.ts +15 -2
- package/server/agent-tools.ts +240 -0
- package/server/init.ts +1 -0
- package/server/live.ts +27 -1
- package/server/mcp-http.ts +152 -0
- package/server/stores.ts +8 -1
- package/server/subagent-logs.ts +146 -0
- package/shared/activity.ts +5 -1
- package/shared/agent-hooks.ts +68 -0
- package/shared/agent-tools.ts +130 -0
- package/shared/graph-camera.ts +93 -0
- package/shared/live.ts +43 -0
- package/shared/prompts.ts +49 -1
- package/shared/settings.ts +31 -0
package/client/live-header.tsx
CHANGED
|
@@ -10,6 +10,7 @@ import { eventTone, KIND_ICONS } from "./live-feed";
|
|
|
10
10
|
import { type IssueProgress, MONO, ProgressBar } from "./live-issues";
|
|
11
11
|
import { type LiveMap, NATIVE_DRIVER, type OwnerColors } from "./live-timeline";
|
|
12
12
|
import { ProviderIcon } from "./provider-icon";
|
|
13
|
+
import { ToolButton, ToolGroup } from "./tool-buttons";
|
|
13
14
|
import { Button, StateIcon, type Theme } from "./ui";
|
|
14
15
|
|
|
15
16
|
type AgentStatus = PluginAgentSnapshot["status"];
|
|
@@ -256,6 +257,34 @@ export function MapSource(props: { theme: Theme; live: LiveMap; mapping: Mapping
|
|
|
256
257
|
);
|
|
257
258
|
}
|
|
258
259
|
|
|
260
|
+
/** Read the file data again, rebuild the map with the explore agent, or clear the saved map. */
|
|
261
|
+
export function MapActions(props: { theme: Theme; live: LiveMap }) {
|
|
262
|
+
const { theme, live } = props;
|
|
263
|
+
const { explore, refresh, clear } = live;
|
|
264
|
+
const running = live.job?.status === "running";
|
|
265
|
+
const rebuild = useCallback(() => explore(true), [explore]);
|
|
266
|
+
return (
|
|
267
|
+
<ToolGroup theme={theme}>
|
|
268
|
+
<ToolButton theme={theme} icon="RefreshCw" label="Read files and links again" onPress={refresh} />
|
|
269
|
+
<ToolButton
|
|
270
|
+
theme={theme}
|
|
271
|
+
icon="Sparkles"
|
|
272
|
+
label={live.stored ? "Rebuild the map with the explore agent" : "Build the map with the explore agent"}
|
|
273
|
+
busy={live.starting || running}
|
|
274
|
+
onPress={rebuild}
|
|
275
|
+
/>
|
|
276
|
+
<ToolButton
|
|
277
|
+
theme={theme}
|
|
278
|
+
icon="Eraser"
|
|
279
|
+
label="Clear the saved map and use the ticket text"
|
|
280
|
+
disabled={!live.stored || running}
|
|
281
|
+
busy={live.clearing}
|
|
282
|
+
onPress={clear}
|
|
283
|
+
/>
|
|
284
|
+
</ToolGroup>
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
259
288
|
export function ViewToggle(props: {
|
|
260
289
|
theme: Theme;
|
|
261
290
|
value: LiveView;
|
package/client/live-panel.tsx
CHANGED
|
@@ -6,13 +6,13 @@ import { type LayoutChangeEvent, Text, View } from "react-native";
|
|
|
6
6
|
import { deriveActivity } from "../shared/activity";
|
|
7
7
|
import type { IssueDetail } from "../shared/linear";
|
|
8
8
|
import { AGENT_LABELS } from "../shared/prompts";
|
|
9
|
-
import { type LinearSettings, type LiveView, linearSettings, type MappingMode } from "../shared/settings";
|
|
9
|
+
import { type GraphCamera, type LinearSettings, type LiveView, linearSettings, type MappingMode } from "../shared/settings";
|
|
10
10
|
import { KeyScopeProvider } from "./key-scope";
|
|
11
11
|
import { buildGraph, graphFiles } from "../shared/graph-model";
|
|
12
12
|
import { AgentsList, cursorAgents, useLiveAgents } from "./live-agents";
|
|
13
13
|
import { ActivityFeed } from "./live-feed";
|
|
14
14
|
import { LiveGraph } from "./live-graph";
|
|
15
|
-
import { LiveHeader, MapSource, ViewToggle } from "./live-header";
|
|
15
|
+
import { LiveHeader, MapActions, MapSource, ViewToggle } from "./live-header";
|
|
16
16
|
import { type FocusKey, IssueRail, issueProgress, SubIssues } from "./live-issues";
|
|
17
17
|
import { MapLegend, RepoMap } from "./live-map";
|
|
18
18
|
import {
|
|
@@ -33,6 +33,7 @@ interface AgentInfo {
|
|
|
33
33
|
identifier: string | null;
|
|
34
34
|
project: string | null;
|
|
35
35
|
cwd: string;
|
|
36
|
+
workspaceId: string | null;
|
|
36
37
|
status: AgentStatus;
|
|
37
38
|
provider: string;
|
|
38
39
|
}
|
|
@@ -41,11 +42,14 @@ const selectAgent = (agent: PluginAgentSnapshot): AgentInfo => ({
|
|
|
41
42
|
identifier: agent.labels[AGENT_LABELS.issue] ?? null,
|
|
42
43
|
project: agent.labels[AGENT_LABELS.project] ?? null,
|
|
43
44
|
cwd: agent.cwd,
|
|
45
|
+
workspaceId: agent.workspaceId ?? null,
|
|
44
46
|
status: agent.status,
|
|
45
47
|
provider: agent.provider,
|
|
46
48
|
});
|
|
47
49
|
|
|
48
|
-
type LivePatch = Partial<
|
|
50
|
+
export type LivePatch = Partial<
|
|
51
|
+
Pick<LinearSettings["live"], "view" | "issuesCollapsed" | "graphCamera" | "graphLocked">
|
|
52
|
+
>;
|
|
49
53
|
|
|
50
54
|
const STACK_WIDTH = 760;
|
|
51
55
|
const COLUMN_WIDTH = 330;
|
|
@@ -103,6 +107,8 @@ export function LivePanel(props: PluginAgentPanelProps) {
|
|
|
103
107
|
mapping={live?.mapping ?? "semantic"}
|
|
104
108
|
view={live?.view ?? "map"}
|
|
105
109
|
issuesCollapsed={live?.issuesCollapsed ?? false}
|
|
110
|
+
graphCamera={live?.graphCamera ?? "auto"}
|
|
111
|
+
graphLocked={live?.graphLocked ?? false}
|
|
106
112
|
saveLive={saveLive}
|
|
107
113
|
openAgent={props.navigation ? (id) => props.navigation?.openAgent({ agentId: id }) : undefined}
|
|
108
114
|
/>
|
|
@@ -119,6 +125,8 @@ interface BodyProps {
|
|
|
119
125
|
mapping: MappingMode;
|
|
120
126
|
view: LiveView;
|
|
121
127
|
issuesCollapsed: boolean;
|
|
128
|
+
graphCamera: GraphCamera;
|
|
129
|
+
graphLocked: boolean;
|
|
122
130
|
saveLive(patch: LivePatch): void;
|
|
123
131
|
openAgent?: (agentId: string) => void;
|
|
124
132
|
}
|
|
@@ -206,6 +214,7 @@ function LiveView(props: BodyProps & { issue: IssueDetail }) {
|
|
|
206
214
|
theme,
|
|
207
215
|
agentId,
|
|
208
216
|
cwd: agent.cwd,
|
|
217
|
+
workspaceId: agent.workspaceId,
|
|
209
218
|
status: agent.status,
|
|
210
219
|
provider: agent.provider,
|
|
211
220
|
activity,
|
|
@@ -249,8 +258,16 @@ function LiveView(props: BodyProps & { issue: IssueDetail }) {
|
|
|
249
258
|
[view, graphInput, links.links],
|
|
250
259
|
);
|
|
251
260
|
const cursors = useMemo(
|
|
252
|
-
() =>
|
|
253
|
-
|
|
261
|
+
() =>
|
|
262
|
+
cursorAgents({
|
|
263
|
+
theme,
|
|
264
|
+
provider: agent.provider,
|
|
265
|
+
working,
|
|
266
|
+
activity,
|
|
267
|
+
children: agents.children,
|
|
268
|
+
subagents: agents.subagents,
|
|
269
|
+
}),
|
|
270
|
+
[theme, agent.provider, working, activity, agents.children, agents.subagents],
|
|
254
271
|
);
|
|
255
272
|
const owners = useMemo(() => ownerColors(issue, colors.accent), [issue, colors.accent]);
|
|
256
273
|
const progress = useMemo(() => issueProgress(issue, activity.todos), [issue, activity.todos]);
|
|
@@ -322,6 +339,7 @@ function LiveView(props: BodyProps & { issue: IssueDetail }) {
|
|
|
322
339
|
right={
|
|
323
340
|
<View style={styles.tools}>
|
|
324
341
|
{stacked || view === "graph" ? null : <MapLegend theme={theme} />}
|
|
342
|
+
<MapActions theme={theme} live={liveMap} />
|
|
325
343
|
{toggle}
|
|
326
344
|
</View>
|
|
327
345
|
}
|
|
@@ -346,6 +364,9 @@ function LiveView(props: BodyProps & { issue: IssueDetail }) {
|
|
|
346
364
|
agents={cursors}
|
|
347
365
|
focus={focus}
|
|
348
366
|
onFocus={toggleFocus}
|
|
367
|
+
camera={props.graphCamera}
|
|
368
|
+
locked={props.graphLocked}
|
|
369
|
+
saveLive={saveLive}
|
|
349
370
|
reduceMotion={reduceMotion}
|
|
350
371
|
/>
|
|
351
372
|
) : (
|
|
@@ -367,14 +388,14 @@ function LiveView(props: BodyProps & { issue: IssueDetail }) {
|
|
|
367
388
|
<Card theme={theme} title="Activity">
|
|
368
389
|
<ActivityFeed
|
|
369
390
|
theme={theme}
|
|
370
|
-
events={
|
|
391
|
+
events={agents.events}
|
|
371
392
|
loading={timeline.loading}
|
|
372
393
|
error={timeline.error}
|
|
373
394
|
reduceMotion={reduceMotion}
|
|
374
395
|
/>
|
|
375
396
|
</Card>
|
|
376
397
|
);
|
|
377
|
-
const helpers = agents.children.length +
|
|
398
|
+
const helpers = agents.children.length + agents.subagents.length;
|
|
378
399
|
const agentsCard =
|
|
379
400
|
helpers > 0 ? (
|
|
380
401
|
<Card theme={theme} title="Agents" meta={<Text style={styles.hit}>{`${helpers + 1}`}</Text>}>
|
|
@@ -384,7 +405,7 @@ function LiveView(props: BodyProps & { issue: IssueDetail }) {
|
|
|
384
405
|
status={agent.status}
|
|
385
406
|
current={activity.current}
|
|
386
407
|
childAgents={agents.children}
|
|
387
|
-
subagents={
|
|
408
|
+
subagents={agents.subagents}
|
|
388
409
|
onOpenAgent={props.openAgent}
|
|
389
410
|
/>
|
|
390
411
|
</Card>
|
package/client/live-timeline.ts
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import { usePaseo, useRpc } from "@getpaseo/plugin/client";
|
|
2
|
-
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
|
|
3
|
-
import { useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
4
4
|
import { AccessibilityInfo, Platform } from "react-native";
|
|
5
5
|
import type { DirTouch, FileTouch, TimelineItemLike } from "../shared/activity";
|
|
6
6
|
import type { IssueDetail } from "../shared/linear";
|
|
7
7
|
import type { FileLink } from "../shared/links";
|
|
8
8
|
import {
|
|
9
|
+
clearMapRpc,
|
|
9
10
|
exploreRpc,
|
|
10
11
|
type IssueMap,
|
|
11
12
|
type Job,
|
|
12
13
|
listFilesRpc,
|
|
13
14
|
linksRpc,
|
|
14
15
|
liveMapRpc,
|
|
16
|
+
type SubagentLog,
|
|
17
|
+
subagentLogsRpc,
|
|
15
18
|
MAX_LINK_FILES,
|
|
16
19
|
MAX_LIST_DIRS,
|
|
17
20
|
type MapFile,
|
|
@@ -244,6 +247,11 @@ export interface LiveMap {
|
|
|
244
247
|
starting: boolean;
|
|
245
248
|
error: string | null;
|
|
246
249
|
explore(force: boolean): void;
|
|
250
|
+
/** Reads the map, folder listings, links, and subagents again. */
|
|
251
|
+
refresh(): void;
|
|
252
|
+
/** Forgets the saved explore map; the map falls back to the ticket text. */
|
|
253
|
+
clear(): void;
|
|
254
|
+
clearing: boolean;
|
|
247
255
|
}
|
|
248
256
|
|
|
249
257
|
export function useLiveMap(
|
|
@@ -270,9 +278,13 @@ export function useLiveMap(
|
|
|
270
278
|
onSettled: () => void refetch(),
|
|
271
279
|
});
|
|
272
280
|
const { mutate } = explore;
|
|
281
|
+
const queries = useQueryClient();
|
|
282
|
+
const refresh = useCallback(() => void queries.invalidateQueries({ queryKey: ["linear", "live"] }), [queries]);
|
|
283
|
+
const clearMap = useRpc(clearMapRpc);
|
|
284
|
+
const clear = useMutation({ mutationFn: () => clearMap({ agentId }), onSettled: refresh });
|
|
273
285
|
const fallback = useMemo(() => semanticMap(issue), [issue]);
|
|
274
286
|
const stored = query.data?.map ?? null;
|
|
275
|
-
const failure = explore.error ?? query.error;
|
|
287
|
+
const failure = explore.error ?? clear.error ?? query.error;
|
|
276
288
|
return {
|
|
277
289
|
map: stored ?? fallback,
|
|
278
290
|
stored: stored !== null,
|
|
@@ -281,6 +293,9 @@ export function useLiveMap(
|
|
|
281
293
|
starting: explore.isPending,
|
|
282
294
|
error: failure ? errorMessage(failure) : null,
|
|
283
295
|
explore: mutate,
|
|
296
|
+
refresh,
|
|
297
|
+
clear: clear.mutate as () => void,
|
|
298
|
+
clearing: clear.isPending,
|
|
284
299
|
};
|
|
285
300
|
}
|
|
286
301
|
|
|
@@ -339,3 +354,23 @@ export function useFileLinks(
|
|
|
339
354
|
ready: sorted.length === 0 || query.data !== undefined || query.isError,
|
|
340
355
|
};
|
|
341
356
|
}
|
|
357
|
+
|
|
358
|
+
const NO_RUNS: readonly SubagentLog[] = [];
|
|
359
|
+
|
|
360
|
+
/** Tool calls of the agent's subagents, read again every few seconds while the agent works. */
|
|
361
|
+
export function useSubagentLogs(
|
|
362
|
+
agentId: string,
|
|
363
|
+
enabled: boolean,
|
|
364
|
+
polling: boolean,
|
|
365
|
+
): { runs: readonly SubagentLog[]; checkedAt: number } {
|
|
366
|
+
const read = useRpc(subagentLogsRpc);
|
|
367
|
+
const query = useQuery({
|
|
368
|
+
queryKey: ["linear", "live", "subagents", agentId],
|
|
369
|
+
queryFn: () => read({ agentId }),
|
|
370
|
+
enabled,
|
|
371
|
+
refetchInterval: polling ? 3_000 : false,
|
|
372
|
+
placeholderData: keepPreviousData,
|
|
373
|
+
staleTime: 2_000,
|
|
374
|
+
});
|
|
375
|
+
return { runs: query.data?.runs ?? NO_RUNS, checkedAt: query.dataUpdatedAt };
|
|
376
|
+
}
|
package/client/settings-live.tsx
CHANGED
|
@@ -26,6 +26,7 @@ const VIEW_OPTIONS: readonly { label: string; value: LiveView }[] = [
|
|
|
26
26
|
{ label: "Graph: issue, files, and agents as connected nodes", value: "graph" },
|
|
27
27
|
];
|
|
28
28
|
const SAME_AGENT = "";
|
|
29
|
+
const MODEL_DEFAULT = "";
|
|
29
30
|
|
|
30
31
|
export function LiveSettings({ theme }: PluginSurfaceProps) {
|
|
31
32
|
const settings = useSettings(linearSettings);
|
|
@@ -62,6 +63,20 @@ function LiveEditor({ theme, settings }: { theme: Theme; settings: ReadySettings
|
|
|
62
63
|
}
|
|
63
64
|
return options;
|
|
64
65
|
}, [providers.data, live.exploreProvider]);
|
|
66
|
+
// Efforts of the chosen model, such as Low to Max, with its default first.
|
|
67
|
+
const effortOptions = useMemo(() => {
|
|
68
|
+
const [agentId, ...rest] = live.exploreProvider.split("/");
|
|
69
|
+
const modelId = rest.join("/");
|
|
70
|
+
const model = providers.data
|
|
71
|
+
?.find((agent) => agent.id === agentId)
|
|
72
|
+
?.models.find((entry) => entry.id === modelId);
|
|
73
|
+
if (!model || model.efforts.length === 0) return null;
|
|
74
|
+
const fallback = model.efforts.find((effort) => effort.id === model.defaultEffortId);
|
|
75
|
+
return [
|
|
76
|
+
{ label: fallback ? `Model default: ${fallback.label}` : "Model default", value: MODEL_DEFAULT },
|
|
77
|
+
...model.efforts.map((effort) => ({ label: effort.label, value: effort.id })),
|
|
78
|
+
];
|
|
79
|
+
}, [providers.data, live.exploreProvider]);
|
|
65
80
|
|
|
66
81
|
return (
|
|
67
82
|
<View>
|
|
@@ -94,8 +109,17 @@ function LiveEditor({ theme, settings }: { theme: Theme; settings: ReadySettings
|
|
|
94
109
|
hint="It may only read and search the project folder. A faster, cheaper model is usually enough."
|
|
95
110
|
value={live.exploreProvider}
|
|
96
111
|
options={agentOptions}
|
|
97
|
-
onValueChange={(exploreProvider) => change({ exploreProvider })}
|
|
112
|
+
onValueChange={(exploreProvider) => change({ exploreProvider, exploreEffort: MODEL_DEFAULT })}
|
|
98
113
|
/>
|
|
114
|
+
{effortOptions ? (
|
|
115
|
+
<SettingsSelect
|
|
116
|
+
label="Explore and setup effort"
|
|
117
|
+
hint="How much the model thinks before it answers. Lower is faster and uses less of your plan."
|
|
118
|
+
value={effortOptions.some((option) => option.value === live.exploreEffort) ? live.exploreEffort : MODEL_DEFAULT}
|
|
119
|
+
options={effortOptions}
|
|
120
|
+
onValueChange={(exploreEffort) => change({ exploreEffort })}
|
|
121
|
+
/>
|
|
122
|
+
) : null}
|
|
99
123
|
</SettingsCard>
|
|
100
124
|
</SettingsSection>
|
|
101
125
|
<SettingsSection title="Linear status sync">
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Icon } from "@getpaseo/plugin/client/react-native";
|
|
2
|
+
import { type ReactNode, useMemo } from "react";
|
|
3
|
+
import { ActivityIndicator, Pressable, View } from "react-native";
|
|
4
|
+
import { type Theme, usePressableStyle } from "./ui";
|
|
5
|
+
|
|
6
|
+
// Small icon buttons for card toolbars: map actions and the graph's camera. A group draws them
|
|
7
|
+
// in one bordered track, like the Map and Graph switch.
|
|
8
|
+
|
|
9
|
+
export function ToolGroup({ theme, children }: { theme: Theme; children: ReactNode }) {
|
|
10
|
+
const { colors } = theme;
|
|
11
|
+
const style = useMemo(
|
|
12
|
+
() =>
|
|
13
|
+
({
|
|
14
|
+
flexDirection: "row",
|
|
15
|
+
alignItems: "center",
|
|
16
|
+
padding: 2,
|
|
17
|
+
gap: 2,
|
|
18
|
+
borderRadius: 8,
|
|
19
|
+
borderWidth: 1,
|
|
20
|
+
borderColor: colors.border,
|
|
21
|
+
backgroundColor: colors.surface1,
|
|
22
|
+
}) as const,
|
|
23
|
+
[colors],
|
|
24
|
+
);
|
|
25
|
+
return <View style={style}>{children}</View>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function ToolButton(props: {
|
|
29
|
+
theme: Theme;
|
|
30
|
+
icon: string;
|
|
31
|
+
/** Read by screen readers and shown as the tooltip on web. */
|
|
32
|
+
label: string;
|
|
33
|
+
onPress(): void;
|
|
34
|
+
/** A toggle that is on. */
|
|
35
|
+
active?: boolean;
|
|
36
|
+
disabled?: boolean;
|
|
37
|
+
busy?: boolean;
|
|
38
|
+
}) {
|
|
39
|
+
const { colors } = props.theme;
|
|
40
|
+
const styles = useMemo(() => {
|
|
41
|
+
const idle = {
|
|
42
|
+
width: 26,
|
|
43
|
+
height: 24,
|
|
44
|
+
borderRadius: 6,
|
|
45
|
+
alignItems: "center",
|
|
46
|
+
justifyContent: "center",
|
|
47
|
+
backgroundColor: props.active ? colors.surface2 : "transparent",
|
|
48
|
+
opacity: props.disabled ? 0.4 : 1,
|
|
49
|
+
} as const;
|
|
50
|
+
return { idle, hover: { ...idle, backgroundColor: colors.surface2 } };
|
|
51
|
+
}, [colors, props.active, props.disabled]);
|
|
52
|
+
const pressStyle = usePressableStyle(styles.idle, styles.hover);
|
|
53
|
+
const color = props.active ? colors.foreground : colors.foregroundMuted;
|
|
54
|
+
return (
|
|
55
|
+
<Pressable
|
|
56
|
+
accessibilityRole="button"
|
|
57
|
+
accessibilityLabel={props.label}
|
|
58
|
+
accessibilityState={{ selected: props.active === true, disabled: props.disabled === true }}
|
|
59
|
+
disabled={props.disabled || props.busy}
|
|
60
|
+
onPress={props.onPress}
|
|
61
|
+
hitSlop={4}
|
|
62
|
+
style={pressStyle}
|
|
63
|
+
{...({ title: props.label } as object)}
|
|
64
|
+
>
|
|
65
|
+
{props.busy ? <ActivityIndicator size="small" color={color} /> : <Icon name={props.icon} size={14} color={color} />}
|
|
66
|
+
</Pressable>
|
|
67
|
+
);
|
|
68
|
+
}
|
package/client/web.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Platform } from "react-native";
|
|
2
|
+
|
|
3
|
+
// The only module that uses the DOM. React wheel listeners are passive, so they cannot keep
|
|
4
|
+
// the page from scrolling while the wheel zooms the graph. Off the web each export does nothing.
|
|
5
|
+
|
|
6
|
+
interface WheelEventLike {
|
|
7
|
+
deltaY: number;
|
|
8
|
+
deltaMode: number;
|
|
9
|
+
clientX: number;
|
|
10
|
+
clientY: number;
|
|
11
|
+
preventDefault(): void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type WheelListener = (event: WheelEventLike) => void;
|
|
15
|
+
|
|
16
|
+
interface ElementLike {
|
|
17
|
+
addEventListener(type: "wheel", listener: WheelListener, options: { passive: boolean }): void;
|
|
18
|
+
removeEventListener(type: "wheel", listener: WheelListener): void;
|
|
19
|
+
getBoundingClientRect(): { left: number; top: number };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isElement(node: unknown): node is ElementLike {
|
|
23
|
+
if (typeof node !== "object" || node === null) return false;
|
|
24
|
+
const element = node as Partial<ElementLike>;
|
|
25
|
+
return typeof element.addEventListener === "function" && typeof element.getBoundingClientRect === "function";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const LINE = 16;
|
|
29
|
+
const PAGE = 400;
|
|
30
|
+
|
|
31
|
+
/** Calls onWheel with the point under the pointer and a zoom factor. Returns the cleanup. */
|
|
32
|
+
export function listenWheel(
|
|
33
|
+
node: unknown,
|
|
34
|
+
onWheel: (point: { x: number; y: number }, factor: number) => void,
|
|
35
|
+
): () => void {
|
|
36
|
+
if (Platform.OS !== "web" || !isElement(node)) return () => {};
|
|
37
|
+
const listener: WheelListener = (event) => {
|
|
38
|
+
event.preventDefault();
|
|
39
|
+
const rect = node.getBoundingClientRect();
|
|
40
|
+
const pixels = event.deltaY * (event.deltaMode === 1 ? LINE : event.deltaMode === 2 ? PAGE : 1);
|
|
41
|
+
onWheel({ x: event.clientX - rect.left, y: event.clientY - rect.top }, Math.exp(-pixels * 0.002));
|
|
42
|
+
};
|
|
43
|
+
node.addEventListener("wheel", listener, { passive: false });
|
|
44
|
+
return () => node.removeEventListener("wheel", listener);
|
|
45
|
+
}
|
package/index.server.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
|
3
|
+
import { registerAgentHooks } from "./server/agent-hooks";
|
|
4
|
+
import { registerAgentTools } from "./server/agent-tools";
|
|
3
5
|
import { createCredentialStore, defaultCredentialPath } from "./server/credentials";
|
|
4
6
|
import { registerHandlers } from "./server/handlers";
|
|
5
7
|
import { registerInit } from "./server/init";
|
|
@@ -24,9 +26,11 @@ export default function contribute(server: PluginServerContext) {
|
|
|
24
26
|
registerLive(server, { access, maps, launches, readSettings });
|
|
25
27
|
registerSync(server, { access, readSettings });
|
|
26
28
|
registerInit(server, { readSettings });
|
|
29
|
+
registerAgentHooks(server, dataDirectory);
|
|
30
|
+
const closeTools = registerAgentTools(server, { access, dataDirectory });
|
|
27
31
|
server.handle(accessRpc, async () => {
|
|
28
32
|
const values = await readSettings();
|
|
29
33
|
return values?.access ?? linearSettings.schema.parse({}).access;
|
|
30
34
|
});
|
|
31
|
-
return
|
|
35
|
+
return closeTools;
|
|
32
36
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
|
5
|
+
import { agentHooksRpc, claudeHooks, hookPluginName, IDENTIFIER } from "../shared/agent-hooks";
|
|
6
|
+
|
|
7
|
+
// Writes one Claude Code plugin folder per issue under the plugin's data folder. A launch passes
|
|
8
|
+
// the folder to Claude with --plugin-dir. The name holds a hash of the hooks, so a later launch
|
|
9
|
+
// never changes the hooks of a running agent. Resumed agents keep using it, so it stays.
|
|
10
|
+
|
|
11
|
+
async function writeAtomic(file: string, text: string) {
|
|
12
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
13
|
+
await writeFile(temporary, text, { mode: 0o600 });
|
|
14
|
+
await rename(temporary, file);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function registerAgentHooks(server: PluginServerContext, dataDirectory: string) {
|
|
18
|
+
server.handle(agentHooksRpc, async ({ issue, guidance }) => {
|
|
19
|
+
// The hooks run printf in a POSIX shell, which Windows hosts do not have.
|
|
20
|
+
if (process.platform === "win32" || !IDENTIFIER.test(issue.identifier)) return { pluginDir: null };
|
|
21
|
+
const hooks = claudeHooks(issue, guidance);
|
|
22
|
+
if (!hooks) return { pluginDir: null };
|
|
23
|
+
const text = `${JSON.stringify(hooks, null, 2)}\n`;
|
|
24
|
+
const hash = createHash("sha256").update(text).digest("hex").slice(0, 12);
|
|
25
|
+
const root = path.join(dataDirectory, "claude-hooks", `${issue.identifier.toUpperCase()}-${hash}`);
|
|
26
|
+
await mkdir(path.join(root, ".claude-plugin"), { recursive: true, mode: 0o700 });
|
|
27
|
+
await mkdir(path.join(root, "hooks"), { recursive: true, mode: 0o700 });
|
|
28
|
+
const manifest = {
|
|
29
|
+
name: hookPluginName(issue.identifier),
|
|
30
|
+
version: "1.0.0",
|
|
31
|
+
description: `Linear context for ${issue.identifier} from the Paseo Linear plugin`,
|
|
32
|
+
};
|
|
33
|
+
await writeAtomic(path.join(root, ".claude-plugin", "plugin.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
34
|
+
await writeAtomic(path.join(root, "hooks", "hooks.json"), text);
|
|
35
|
+
return { pluginDir: root };
|
|
36
|
+
});
|
|
37
|
+
}
|
package/server/agent-runs.ts
CHANGED
|
@@ -20,6 +20,8 @@ export interface InternalRun<T> {
|
|
|
20
20
|
/** Folder the agent works in; its reads must stay inside. */
|
|
21
21
|
cwd: string;
|
|
22
22
|
provider: string;
|
|
23
|
+
/** Thinking option for the model in `provider`; ignored when another model runs. */
|
|
24
|
+
effort?: string;
|
|
23
25
|
title: string;
|
|
24
26
|
prompt: string;
|
|
25
27
|
labels: Record<string, string>;
|
|
@@ -144,7 +146,7 @@ export async function agentConfigFor(paseo: Paseo, preferred: string) {
|
|
|
144
146
|
return { provider, ...(modeId ? { modeId } : {}) };
|
|
145
147
|
}
|
|
146
148
|
|
|
147
|
-
async function lastReply(handle: AgentHandle) {
|
|
149
|
+
export async function lastReply(handle: AgentHandle) {
|
|
148
150
|
const page = await handle.timeline.refetch({
|
|
149
151
|
direction: "tail",
|
|
150
152
|
limit: 60,
|
|
@@ -221,8 +223,14 @@ export interface StartedRun<T> {
|
|
|
221
223
|
result: Promise<T>;
|
|
222
224
|
}
|
|
223
225
|
|
|
226
|
+
/** Adds the chosen effort only when the chosen model runs, not a fallback provider. */
|
|
227
|
+
export function withEffort<C extends { provider: string }>(config: C, chosen: string, effort?: string) {
|
|
228
|
+
const applies = Boolean(effort) && chosen.includes("/") && config.provider === chosen;
|
|
229
|
+
return applies ? { ...config, thinkingOptionId: effort as string } : config;
|
|
230
|
+
}
|
|
231
|
+
|
|
224
232
|
export async function startInternalRun<T>(run: InternalRun<T>): Promise<StartedRun<T>> {
|
|
225
|
-
const config = await agentConfigFor(run.paseo, run.provider);
|
|
233
|
+
const config = withEffort(await agentConfigFor(run.paseo, run.provider), run.provider, run.effort);
|
|
226
234
|
const handle = await run.workspace.agents.create({
|
|
227
235
|
config,
|
|
228
236
|
title: run.title,
|
|
@@ -239,6 +247,8 @@ export interface JobStore<T> {
|
|
|
239
247
|
start(id: string | null, begin: () => Promise<StartedRun<T>>): Promise<Job>;
|
|
240
248
|
/** Resolves with the job once it is done or failed; null for an unknown id. */
|
|
241
249
|
wait(id: string): Promise<Job | null>;
|
|
250
|
+
/** Drops a finished job, so the next start makes a new run. A running job is kept. */
|
|
251
|
+
forget(id: string): void;
|
|
242
252
|
}
|
|
243
253
|
|
|
244
254
|
interface JobRecord<T> {
|
|
@@ -254,6 +264,9 @@ export function createJobStore<T>(): JobStore<T> {
|
|
|
254
264
|
const record = jobs.get(id);
|
|
255
265
|
return record ? { job: record.job, value: record.value } : null;
|
|
256
266
|
},
|
|
267
|
+
forget(id) {
|
|
268
|
+
if (jobs.get(id)?.job.status !== "running") jobs.delete(id);
|
|
269
|
+
},
|
|
257
270
|
async wait(id) {
|
|
258
271
|
const record = jobs.get(id);
|
|
259
272
|
if (!record) return null;
|