@yannelli/paseo-linear-plugin 1.1.0-beta.0 → 1.1.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/OVERVIEW.md +7 -3
  2. package/README.md +38 -11
  3. package/client/access.tsx +2 -1
  4. package/client/compat.ts +23 -5
  5. package/client/connect.tsx +3 -2
  6. package/client/graph-camera.tsx +219 -0
  7. package/client/graph-labels.ts +14 -4
  8. package/client/launch-guidance.tsx +86 -0
  9. package/client/launch-plan.ts +50 -2
  10. package/client/launch.tsx +10 -1
  11. package/client/live-agents.tsx +138 -23
  12. package/client/live-cursor.tsx +3 -1
  13. package/client/live-graph.tsx +159 -75
  14. package/client/live-header.tsx +29 -0
  15. package/client/live-panel.tsx +29 -8
  16. package/client/live-timeline.ts +38 -3
  17. package/client/pill.tsx +4 -3
  18. package/client/plugin-icon.tsx +43 -0
  19. package/client/settings-fields.tsx +11 -3
  20. package/client/settings-icon.tsx +228 -0
  21. package/client/settings-live.tsx +25 -1
  22. package/client/timeline-card.tsx +2 -1
  23. package/client/tool-buttons.tsx +68 -0
  24. package/client/ui.tsx +16 -7
  25. package/client/web.ts +91 -0
  26. package/index.client.tsx +9 -0
  27. package/index.server.ts +5 -1
  28. package/package.json +1 -1
  29. package/server/agent-hooks.ts +37 -0
  30. package/server/agent-runs.ts +15 -2
  31. package/server/agent-tools.ts +240 -0
  32. package/server/init.ts +1 -0
  33. package/server/live.ts +27 -1
  34. package/server/mcp-http.ts +152 -0
  35. package/server/stores.ts +8 -1
  36. package/server/subagent-logs.ts +146 -0
  37. package/shared/activity.ts +5 -1
  38. package/shared/agent-hooks.ts +68 -0
  39. package/shared/agent-tools.ts +130 -0
  40. package/shared/custom-icon.ts +148 -0
  41. package/shared/graph-camera.ts +93 -0
  42. package/shared/live.ts +43 -0
  43. package/shared/prompts.ts +49 -1
  44. package/shared/settings.ts +34 -0
@@ -5,6 +5,7 @@ import { type ReactNode, useCallback, useMemo, useState } from "react";
5
5
  import { Text, View } from "react-native";
6
6
  import { TEMPLATE_VARIABLES } from "../shared/prompts";
7
7
  import type { LinearSettings, linearSettings } from "../shared/settings";
8
+ import { MONO } from "./live-issues";
8
9
  import type { Theme } from "./ui";
9
10
 
10
11
  export type ReadySettings = Extract<
@@ -19,10 +20,14 @@ export function MultilineField(props: {
19
20
  value: string;
20
21
  placeholder?: string;
21
22
  minHeight?: number;
23
+ error?: string | null;
24
+ /** Markup, not prose: a monospace font and no autocorrect. */
25
+ code?: boolean;
22
26
  onChange(value: string): void;
23
27
  }) {
24
28
  const { colors } = props.theme;
25
29
  const minHeight = props.minHeight ?? 96;
30
+ const { code } = props;
26
31
  const style = useMemo(
27
32
  () => ({
28
33
  marginTop: 8,
@@ -36,11 +41,12 @@ export function MultilineField(props: {
36
41
  fontSize: 13,
37
42
  lineHeight: 19,
38
43
  textAlignVertical: "top" as const,
44
+ ...(code ? { fontFamily: MONO, fontSize: 12 } : {}),
39
45
  }),
40
- [colors, minHeight],
46
+ [colors, minHeight, code],
41
47
  );
42
48
  return (
43
- <SettingsRow label={props.label} hint={props.hint}>
49
+ <SettingsRow label={props.label} hint={props.hint} error={props.error}>
44
50
  <TextInput
45
51
  value={props.value}
46
52
  onChangeText={props.onChange}
@@ -48,7 +54,9 @@ export function MultilineField(props: {
48
54
  placeholder={props.placeholder}
49
55
  placeholderTextColor={colors.foregroundMuted}
50
56
  accessibilityLabel={props.label}
51
- autoCapitalize="sentences"
57
+ autoCapitalize={code ? "none" : "sentences"}
58
+ autoCorrect={!code}
59
+ spellCheck={!code}
52
60
  style={style}
53
61
  />
54
62
  </SettingsRow>
@@ -0,0 +1,228 @@
1
+ import { type PluginSurfaceProps, useSettings } from "@getpaseo/plugin/client";
2
+ import {
3
+ SettingsAction,
4
+ SettingsCard,
5
+ SettingsInput,
6
+ SettingsRow,
7
+ SettingsSection,
8
+ SettingsSelect,
9
+ SettingsSwitch,
10
+ } from "@getpaseo/plugin/client/ui";
11
+ import { useCallback, useMemo, useState } from "react";
12
+ import { Platform, Text, View } from "react-native";
13
+ import {
14
+ HEX_COLOR,
15
+ type IconPaint,
16
+ type IconSettings,
17
+ MAX_SVG_LENGTH,
18
+ parseIconSvg,
19
+ } from "../shared/custom-icon";
20
+ import { linearSettings } from "../shared/settings";
21
+ import { IconGlyph } from "./plugin-icon";
22
+ import { MultilineField, type ReadySettings, SaveBar, SettingsGate, useSettingsDraft } from "./settings-fields";
23
+ import type { Theme } from "./ui";
24
+ import { canPickFiles, pickTextFile } from "./web";
25
+
26
+ const PAINT_OPTIONS: readonly { label: string; value: IconPaint }[] = [
27
+ { label: "Original colors", value: "original" },
28
+ { label: "Theme color, like Paseo's icons", value: "theme" },
29
+ { label: "Theme accent", value: "accent" },
30
+ { label: "Linear indigo, the plugin color", value: "linear" },
31
+ { label: "Custom color", value: "custom" },
32
+ ];
33
+ const PREVIEW_SIZES = [14, 16, 22, 28] as const;
34
+ const SVG_FILES = ".svg,image/svg+xml";
35
+ /** Exports often carry comments and metadata, so the file may be larger than the saved SVG. */
36
+ const MAX_FILE_BYTES = MAX_SVG_LENGTH * 4;
37
+
38
+ export function IconSettingsScreen({ theme }: PluginSurfaceProps) {
39
+ const settings = useSettings(linearSettings);
40
+ const render = useCallback(
41
+ (ready: ReadySettings) => <IconEditor theme={theme} settings={ready} />,
42
+ [theme],
43
+ );
44
+ return (
45
+ <SettingsGate theme={theme} title="Icon" settings={settings}>
46
+ {render}
47
+ </SettingsGate>
48
+ );
49
+ }
50
+
51
+ function IconPreview({ theme, icon }: { theme: Theme; icon: IconSettings }) {
52
+ const { colors } = theme;
53
+ const styles = useMemo(
54
+ () => ({
55
+ row: { flexDirection: "row" as const, flexWrap: "wrap" as const, alignItems: "center" as const, gap: 14, paddingTop: 8 },
56
+ pill: {
57
+ flexDirection: "row" as const,
58
+ alignItems: "center" as const,
59
+ gap: 6,
60
+ height: 26,
61
+ paddingHorizontal: 10,
62
+ borderRadius: 13,
63
+ borderWidth: 1,
64
+ borderColor: colors.border,
65
+ backgroundColor: colors.surface1,
66
+ },
67
+ label: { color: colors.foreground, fontSize: 12 },
68
+ }),
69
+ [colors],
70
+ );
71
+ return (
72
+ <View style={styles.row}>
73
+ {PREVIEW_SIZES.map((size) => (
74
+ <IconGlyph key={size} icon={icon} size={size} color={colors.foregroundMuted} theme={theme} />
75
+ ))}
76
+ <View style={styles.pill}>
77
+ <IconGlyph icon={icon} size={14} color={colors.foregroundMuted} theme={theme} />
78
+ <Text style={styles.label}>ENG-123</Text>
79
+ </View>
80
+ <View style={styles.pill}>
81
+ <IconGlyph icon={icon} size={14} color={colors.foreground} theme={theme} />
82
+ <Text style={styles.label}>Linear</Text>
83
+ </View>
84
+ </View>
85
+ );
86
+ }
87
+
88
+ function IconEditor({ theme, settings }: { theme: Theme; settings: ReadySettings }) {
89
+ const { values, dirty, edit, save, discard } = useSettingsDraft(settings);
90
+ const icon = values.icon;
91
+ const [markup, setMarkup] = useState(icon.svg);
92
+ const [markupError, setMarkupError] = useState<string | null>(null);
93
+ const [colorError, setColorError] = useState<string | null>(null);
94
+ const [fileName, setFileName] = useState<string | null>(null);
95
+ const change = useCallback(
96
+ (patch: Partial<IconSettings>) => edit((current) => ({ ...current, icon: { ...current.icon, ...patch } })),
97
+ [edit],
98
+ );
99
+ // The draft keeps the last SVG that passed the checks, so the preview never breaks.
100
+ const applyMarkup = useCallback(
101
+ (text: string) => {
102
+ setMarkup(text);
103
+ if (!text.trim()) {
104
+ setMarkupError(null);
105
+ change({ svg: "" });
106
+ return;
107
+ }
108
+ const parsed = parseIconSvg(text);
109
+ setMarkupError("error" in parsed ? parsed.error : null);
110
+ if ("svg" in parsed) change({ svg: parsed.svg });
111
+ },
112
+ [change],
113
+ );
114
+ const choose = useCallback(
115
+ () =>
116
+ pickTextFile(SVG_FILES, MAX_FILE_BYTES, (file) => {
117
+ if ("error" in file) {
118
+ setMarkupError(file.error);
119
+ return;
120
+ }
121
+ setFileName(file.name);
122
+ applyMarkup(file.text);
123
+ }),
124
+ [applyMarkup],
125
+ );
126
+ const useBuiltIn = useCallback(() => {
127
+ setFileName(null);
128
+ applyMarkup("");
129
+ }, [applyMarkup]);
130
+ const changeColor = useCallback(
131
+ (text: string) => {
132
+ const color = text.trim();
133
+ const valid = HEX_COLOR.test(color);
134
+ setColorError(valid ? null : "Use a hex color, such as #5E6AD2.");
135
+ if (valid) change({ color });
136
+ },
137
+ [change],
138
+ );
139
+ const reset = useCallback(() => {
140
+ discard();
141
+ setMarkup(settings.values.icon.svg);
142
+ setMarkupError(null);
143
+ setColorError(null);
144
+ setFileName(null);
145
+ }, [discard, settings.values.icon.svg]);
146
+ const native = Platform.OS !== "web";
147
+ const custom = icon.svg !== "";
148
+
149
+ return (
150
+ <View>
151
+ <SettingsSection title="Plugin icon">
152
+ <SettingsCard>
153
+ <SettingsRow
154
+ label="Preview"
155
+ hint={
156
+ native
157
+ ? "The iOS and Android apps cannot draw plugin SVG images, so they show the built-in icon in the color you choose."
158
+ : "Composer pills, the Linear sidebar row, and Linear screens use this icon. Paseo menus and panel tabs keep the built-in icon."
159
+ }
160
+ >
161
+ <IconPreview theme={theme} icon={icon} />
162
+ </SettingsRow>
163
+ {canPickFiles ? (
164
+ <SettingsAction
165
+ label="SVG file"
166
+ hint={fileName ? `Loaded ${fileName}. Save to use it.` : "Choose an .svg file. The plugin saves a copy in its settings."}
167
+ actionLabel="Choose file"
168
+ onPress={choose}
169
+ />
170
+ ) : null}
171
+ <MultilineField
172
+ theme={theme}
173
+ label="SVG markup"
174
+ hint="Paste SVG markup, or edit it here. Scripts, links to other files, and bitmap images are not allowed. Clear it to use the built-in icon."
175
+ value={markup}
176
+ placeholder={'<svg viewBox="0 0 24 24">…</svg>'}
177
+ error={markupError}
178
+ code
179
+ onChange={applyMarkup}
180
+ />
181
+ <SettingsAction
182
+ label="Built-in icon"
183
+ hint="Remove your SVG and use the Linear board icon."
184
+ actionLabel="Use built-in"
185
+ disabled={!custom && markup === ""}
186
+ onPress={useBuiltIn}
187
+ />
188
+ </SettingsCard>
189
+ </SettingsSection>
190
+ <SettingsSection title="Color and fill">
191
+ <SettingsCard>
192
+ <SettingsSelect
193
+ label="Color"
194
+ hint="Original colors keep the SVG as drawn, and currentColor follows Paseo. The other choices paint the whole icon one color."
195
+ value={icon.paint}
196
+ options={PAINT_OPTIONS}
197
+ onValueChange={(paint) => change({ paint })}
198
+ />
199
+ {icon.paint === "custom" ? (
200
+ <SettingsInput
201
+ label="Custom color"
202
+ hint="A hex color, such as #5E6AD2 or #5E6AD2CC with transparency."
203
+ initialValue={icon.color}
204
+ placeholder="#5E6AD2"
205
+ error={colorError}
206
+ onChangeText={changeColor}
207
+ />
208
+ ) : null}
209
+ <SettingsSwitch
210
+ label="Solid"
211
+ hint={custom ? "Fill shapes that only have an outline, so line icons show as solid shapes." : "Add your SVG to use this. The built-in icon stays as drawn."}
212
+ value={icon.solid}
213
+ disabled={!custom}
214
+ onValueChange={(solid) => change({ solid })}
215
+ />
216
+ </SettingsCard>
217
+ </SettingsSection>
218
+ <SaveBar
219
+ theme={theme}
220
+ dirty={dirty}
221
+ saving={settings.saving}
222
+ error={settings.saveError}
223
+ onSave={() => void save()}
224
+ onDiscard={reset}
225
+ />
226
+ </View>
227
+ );
228
+ }
@@ -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">
@@ -4,6 +4,7 @@ import { useCallback, useMemo } from "react";
4
4
  import { Pressable, Text, View } from "react-native";
5
5
  import type { IssueCard } from "../shared/linear";
6
6
  import { ACTION_LABELS } from "../shared/prompts";
7
+ import { PluginIcon } from "./plugin-icon";
7
8
  import { usePressableStyle } from "./ui";
8
9
 
9
10
  export function IssueCardRow({ item, theme }: PluginTimelineItemProps<IssueCard>) {
@@ -44,7 +45,7 @@ export function IssueCardRow({ item, theme }: PluginTimelineItemProps<IssueCard>
44
45
  onPress={open}
45
46
  style={style}
46
47
  >
47
- <Icon name="SquareKanban" size={16} color={colors.foregroundMuted} />
48
+ <PluginIcon size={16} color={colors.foregroundMuted} theme={theme} />
48
49
  <View style={styles.body}>
49
50
  <Text numberOfLines={1} style={styles.title}>
50
51
  <Text style={styles.identifier}>{`${card.identifier} `}</Text>
@@ -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/ui.tsx CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
2
2
  import { Icon } from "@getpaseo/plugin/client/react-native";
3
- import { type ReactNode, useCallback, useMemo } from "react";
3
+ import { type ComponentType, type ReactNode, useCallback, useMemo } from "react";
4
4
  import {
5
5
  ActivityIndicator,
6
6
  Pressable,
@@ -15,6 +15,15 @@ export { Avatar, LabelPill, PriorityIcon, priorityMeta, StateIcon } from "./glyp
15
15
 
16
16
  export type Theme = PluginSurfaceProps["theme"];
17
17
 
18
+ /** A Lucide icon name, or a component such as the plugin icon. */
19
+ export type Glyph = string | ComponentType<{ size: number; color: string; theme: Theme }>;
20
+
21
+ export function GlyphIcon(props: { glyph: Glyph; size: number; color: string; theme: Theme }) {
22
+ const { glyph: Custom, size, color, theme } = props;
23
+ if (typeof Custom === "string") return <Icon name={Custom} size={size} color={color} />;
24
+ return <Custom size={size} color={color} theme={theme} />;
25
+ }
26
+
18
27
  type InteractionState = PressableStateCallbackType & { hovered?: boolean };
19
28
 
20
29
  export function interactive(state: PressableStateCallbackType): boolean {
@@ -43,7 +52,7 @@ function buttonFill(variant: ButtonVariant, colors: Theme["colors"]): string {
43
52
  export interface ButtonProps {
44
53
  theme: Theme;
45
54
  label: string;
46
- icon?: string;
55
+ icon?: Glyph;
47
56
  variant?: ButtonVariant;
48
57
  size?: keyof typeof HEIGHT;
49
58
  disabled?: boolean;
@@ -52,10 +61,10 @@ export interface ButtonProps {
52
61
  onPress(): void;
53
62
  }
54
63
 
55
- function ButtonGlyph(props: { busy: boolean; icon?: string; size: number; color: string }) {
64
+ function ButtonGlyph(props: { busy: boolean; icon?: Glyph; size: number; color: string; theme: Theme }) {
56
65
  if (props.busy) return <ActivityIndicator size="small" color={props.color} />;
57
66
  if (!props.icon) return null;
58
- return <Icon name={props.icon} size={props.size} color={props.color} />;
67
+ return <GlyphIcon glyph={props.icon} size={props.size} color={props.color} theme={props.theme} />;
59
68
  }
60
69
 
61
70
  export function Button({
@@ -103,7 +112,7 @@ export function Button({
103
112
  onPress={onPress}
104
113
  style={pressStyle}
105
114
  >
106
- <ButtonGlyph busy={busy === true} icon={icon} size={size === "xs" ? 14 : 16} color={ink} />
115
+ <ButtonGlyph busy={busy === true} icon={icon} size={size === "xs" ? 14 : 16} color={ink} theme={theme} />
107
116
  <Text numberOfLines={1} style={styles.label}>
108
117
  {label}
109
118
  </Text>
@@ -276,7 +285,7 @@ export function OptionRow(props: {
276
285
 
277
286
  export function EmptyState(props: {
278
287
  theme: Theme;
279
- icon: string;
288
+ icon: Glyph;
280
289
  title: string;
281
290
  detail?: string;
282
291
  tone?: "default" | "danger";
@@ -309,7 +318,7 @@ export function EmptyState(props: {
309
318
  );
310
319
  return (
311
320
  <View style={styles.root}>
312
- <Icon name={props.icon} size={28} color={colors.foregroundMuted} />
321
+ <GlyphIcon glyph={props.icon} size={28} color={colors.foregroundMuted} theme={props.theme} />
313
322
  <Text style={styles.title}>{props.title}</Text>
314
323
  {props.detail ? <Text style={styles.detail}>{props.detail}</Text> : null}
315
324
  {props.children}
package/client/web.ts ADDED
@@ -0,0 +1,91 @@
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
+ // The file picker is a hidden file input, because plugins cannot use native document pickers.
6
+
7
+ interface WheelEventLike {
8
+ deltaY: number;
9
+ deltaMode: number;
10
+ clientX: number;
11
+ clientY: number;
12
+ preventDefault(): void;
13
+ }
14
+
15
+ type WheelListener = (event: WheelEventLike) => void;
16
+
17
+ interface ElementLike {
18
+ addEventListener(type: "wheel", listener: WheelListener, options: { passive: boolean }): void;
19
+ removeEventListener(type: "wheel", listener: WheelListener): void;
20
+ getBoundingClientRect(): { left: number; top: number };
21
+ }
22
+
23
+ function isElement(node: unknown): node is ElementLike {
24
+ if (typeof node !== "object" || node === null) return false;
25
+ const element = node as Partial<ElementLike>;
26
+ return typeof element.addEventListener === "function" && typeof element.getBoundingClientRect === "function";
27
+ }
28
+
29
+ const LINE = 16;
30
+ const PAGE = 400;
31
+
32
+ /** Calls onWheel with the point under the pointer and a zoom factor. Returns the cleanup. */
33
+ export function listenWheel(
34
+ node: unknown,
35
+ onWheel: (point: { x: number; y: number }, factor: number) => void,
36
+ ): () => void {
37
+ if (Platform.OS !== "web" || !isElement(node)) return () => {};
38
+ const listener: WheelListener = (event) => {
39
+ event.preventDefault();
40
+ const rect = node.getBoundingClientRect();
41
+ const pixels = event.deltaY * (event.deltaMode === 1 ? LINE : event.deltaMode === 2 ? PAGE : 1);
42
+ onWheel({ x: event.clientX - rect.left, y: event.clientY - rect.top }, Math.exp(-pixels * 0.002));
43
+ };
44
+ node.addEventListener("wheel", listener, { passive: false });
45
+ return () => node.removeEventListener("wheel", listener);
46
+ }
47
+
48
+ interface FileLike {
49
+ name: string;
50
+ size: number;
51
+ text(): Promise<string>;
52
+ }
53
+
54
+ interface FileInputLike {
55
+ type: string;
56
+ accept: string;
57
+ files: ArrayLike<FileLike> | null;
58
+ onchange: (() => void) | null;
59
+ click(): void;
60
+ }
61
+
62
+ interface DocumentLike {
63
+ createElement(tag: "input"): FileInputLike;
64
+ }
65
+
66
+ /** True where pickTextFile can open a file picker. */
67
+ export const canPickFiles = Platform.OS === "web";
68
+
69
+ export type PickedFile = { name: string; text: string } | { error: string };
70
+
71
+ /** Opens the browser file picker and calls onPick with the chosen file. Nothing on cancel. */
72
+ export function pickTextFile(accept: string, maxBytes: number, onPick: (file: PickedFile) => void): void {
73
+ const document = (globalThis as { document?: DocumentLike }).document;
74
+ if (!canPickFiles || !document) return;
75
+ const input = document.createElement("input");
76
+ input.type = "file";
77
+ input.accept = accept;
78
+ input.onchange = () => {
79
+ const file = input.files?.[0];
80
+ if (!file) return;
81
+ if (file.size > maxBytes) {
82
+ onPick({ error: `${file.name} is too large. Use a file under ${Math.round(maxBytes / 1000)} KB.` });
83
+ return;
84
+ }
85
+ file.text().then(
86
+ (text) => onPick({ name: file.name, text }),
87
+ () => onPick({ error: `Paseo could not read ${file.name}.` }),
88
+ );
89
+ };
90
+ input.click();
91
+ }
package/index.client.tsx CHANGED
@@ -4,7 +4,9 @@ import { openPluginScreen, registerScreen } from "./client/compat";
4
4
  import { gateAgentPanel } from "./client/access";
5
5
  import { LivePanel } from "./client/live-panel";
6
6
  import { contributeComposerPills } from "./client/pill";
7
+ import { PluginIcon } from "./client/plugin-icon";
7
8
  import { ConnectionSettings } from "./client/settings-connection";
9
+ import { IconSettingsScreen } from "./client/settings-icon";
8
10
  import { LiveSettings } from "./client/settings-live";
9
11
  import { ProjectSettings } from "./client/settings-projects";
10
12
  import { PromptSettings } from "./client/settings-prompts";
@@ -23,6 +25,7 @@ export default function contribute(client: PluginClientContext) {
23
25
  id: SCREEN_ID,
24
26
  title: "Linear",
25
27
  icon: "SquareKanban",
28
+ RowIcon: PluginIcon,
26
29
  Component: LinearScreen,
27
30
  });
28
31
  client.addWorkspacePanel({
@@ -58,6 +61,12 @@ export default function contribute(client: PluginClientContext) {
58
61
  icon: "Radar",
59
62
  Component: LiveSettings,
60
63
  });
64
+ client.addSettingsScreen({
65
+ id: "icon",
66
+ title: "Icon",
67
+ icon: "Shapes",
68
+ Component: IconSettingsScreen,
69
+ });
61
70
  client.addSettingsScreen({
62
71
  id: "projects",
63
72
  title: "Projects",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yannelli/paseo-linear-plugin",
3
- "version": "1.1.0-beta.0",
3
+ "version": "1.1.0-beta.2",
4
4
  "description": "Browse and triage Linear issues in Paseo and start agents from them",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -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
+ }