@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.
Files changed (56) hide show
  1. package/OVERVIEW.md +20 -2
  2. package/README.md +91 -5
  3. package/client/access.tsx +66 -0
  4. package/client/browser.tsx +12 -9
  5. package/client/graph-camera.tsx +219 -0
  6. package/client/graph-labels.ts +147 -0
  7. package/client/launch-choices.ts +15 -4
  8. package/client/launch-guidance.tsx +86 -0
  9. package/client/launch-plan.ts +86 -16
  10. package/client/launch.tsx +19 -5
  11. package/client/live-agents.tsx +522 -0
  12. package/client/live-cursor.tsx +293 -0
  13. package/client/live-feed.tsx +151 -0
  14. package/client/live-graph.tsx +518 -0
  15. package/client/live-header.tsx +341 -0
  16. package/client/live-issues.tsx +345 -0
  17. package/client/live-map.tsx +345 -0
  18. package/client/live-panel.tsx +465 -0
  19. package/client/live-timeline.ts +376 -0
  20. package/client/pill.tsx +393 -0
  21. package/client/project-init.tsx +168 -0
  22. package/client/settings-access.tsx +56 -0
  23. package/client/settings-live.tsx +145 -0
  24. package/client/settings-projects.tsx +9 -0
  25. package/client/tool-buttons.tsx +68 -0
  26. package/client/web.ts +45 -0
  27. package/index.client.tsx +39 -1
  28. package/index.server.ts +29 -5
  29. package/package.json +1 -1
  30. package/server/agent-hooks.ts +37 -0
  31. package/server/agent-runs.ts +312 -0
  32. package/server/agent-tools.ts +240 -0
  33. package/server/handlers.ts +12 -1
  34. package/server/init.ts +65 -0
  35. package/server/links.ts +153 -0
  36. package/server/live.ts +360 -0
  37. package/server/mcp-http.ts +152 -0
  38. package/server/stores.ts +126 -0
  39. package/server/subagent-logs.ts +146 -0
  40. package/server/sync.ts +127 -0
  41. package/shared/activity.ts +312 -0
  42. package/shared/agent-hooks.ts +68 -0
  43. package/shared/agent-tools.ts +130 -0
  44. package/shared/graph-camera.ts +93 -0
  45. package/shared/graph-geometry.ts +122 -0
  46. package/shared/graph-model.ts +319 -0
  47. package/shared/links.ts +252 -0
  48. package/shared/live.ts +243 -0
  49. package/shared/map-model.ts +192 -0
  50. package/shared/project-setup.ts +55 -0
  51. package/shared/prompts.ts +65 -2
  52. package/shared/settings.ts +88 -1
  53. package/shared/shell-activity.ts +309 -0
  54. package/shared/shell-lexer.ts +180 -0
  55. package/shared/subagents.ts +106 -0
  56. package/shared/todo-sync.ts +158 -0
@@ -0,0 +1,145 @@
1
+ import { type PluginSurfaceProps, useSettings } from "@getpaseo/plugin/client";
2
+ import {
3
+ SettingsCard,
4
+ SettingsSection,
5
+ SettingsSelect,
6
+ SettingsSwitch,
7
+ } from "@getpaseo/plugin/client/ui";
8
+ import { useCallback, useMemo } from "react";
9
+ import { View } from "react-native";
10
+ import {
11
+ type LinearSettings,
12
+ type LiveView,
13
+ linearSettings,
14
+ type MappingMode,
15
+ } from "../shared/settings";
16
+ import { useProviders } from "./queries";
17
+ import { type ReadySettings, SaveBar, SettingsGate, useSettingsDraft } from "./settings-fields";
18
+ import type { Theme } from "./ui";
19
+
20
+ const MAPPING_OPTIONS: readonly { label: string; value: MappingMode }[] = [
21
+ { label: "Ticket text: free, rough", value: "semantic" },
22
+ { label: "Explore agent: reads the repo first", value: "explore" },
23
+ ];
24
+ const VIEW_OPTIONS: readonly { label: string; value: LiveView }[] = [
25
+ { label: "Map: files grouped by folder", value: "map" },
26
+ { label: "Graph: issue, files, and agents as connected nodes", value: "graph" },
27
+ ];
28
+ const SAME_AGENT = "";
29
+ const MODEL_DEFAULT = "";
30
+
31
+ export function LiveSettings({ theme }: PluginSurfaceProps) {
32
+ const settings = useSettings(linearSettings);
33
+ const render = useCallback(
34
+ (ready: ReadySettings) => <LiveEditor theme={theme} settings={ready} />,
35
+ [theme],
36
+ );
37
+ return (
38
+ <SettingsGate theme={theme} title="Live" settings={settings}>
39
+ {render}
40
+ </SettingsGate>
41
+ );
42
+ }
43
+
44
+ type Live = LinearSettings["live"];
45
+
46
+ function LiveEditor({ theme, settings }: { theme: Theme; settings: ReadySettings }) {
47
+ const { values, dirty, edit, save, discard } = useSettingsDraft(settings);
48
+ const providers = useProviders();
49
+ const live = values.live;
50
+ const change = useCallback(
51
+ (patch: Partial<Live>) => edit((current) => ({ ...current, live: { ...current.live, ...patch } })),
52
+ [edit],
53
+ );
54
+ const agentOptions = useMemo(() => {
55
+ const options = [{ label: "Same agent as the implementer", value: SAME_AGENT }];
56
+ for (const agent of providers.data ?? []) {
57
+ for (const model of agent.models) {
58
+ options.push({ label: `${agent.label} · ${model.label}`, value: `${agent.id}/${model.id}` });
59
+ }
60
+ }
61
+ if (live.exploreProvider && !options.some((option) => option.value === live.exploreProvider)) {
62
+ options.push({ label: `${live.exploreProvider} · not on this host`, value: live.exploreProvider });
63
+ }
64
+ return options;
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]);
80
+
81
+ return (
82
+ <View>
83
+ <SettingsSection title="Linear Live">
84
+ <SettingsCard>
85
+ <SettingsSwitch
86
+ label="Show Linear Live"
87
+ hint="Track agents started from an issue: sub-issues, todos, and the files they touch."
88
+ value={live.enabled}
89
+ onValueChange={(enabled) => change({ enabled })}
90
+ />
91
+ <SettingsSelect
92
+ label="Map files with"
93
+ hint="Ticket text finds paths named in the issue. The explore agent reads the repository first, and the implement agent starts when the map is ready. It costs provider usage."
94
+ value={live.mapping}
95
+ options={MAPPING_OPTIONS}
96
+ onValueChange={(mapping) => change({ mapping })}
97
+ disabled={!live.enabled}
98
+ />
99
+ <SettingsSelect
100
+ label="Show files as"
101
+ hint="The view Linear Live opens with. Switch it in the panel at any time."
102
+ value={live.view}
103
+ options={VIEW_OPTIONS}
104
+ onValueChange={(view) => change({ view })}
105
+ disabled={!live.enabled}
106
+ />
107
+ <SettingsSelect
108
+ label="Explore and setup agent"
109
+ hint="It may only read and search the project folder. A faster, cheaper model is usually enough."
110
+ value={live.exploreProvider}
111
+ options={agentOptions}
112
+ onValueChange={(exploreProvider) => change({ exploreProvider, exploreEffort: MODEL_DEFAULT })}
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}
123
+ </SettingsCard>
124
+ </SettingsSection>
125
+ <SettingsSection title="Linear status sync">
126
+ <SettingsCard>
127
+ <SettingsSwitch
128
+ label="Update Linear from agent todos"
129
+ hint="After each turn, a todo that starts with a sub-issue key moves that sub-issue forward: In Progress, then Done. The parent moves to In Review when every sub-issue is Done. Statuses never move back."
130
+ value={live.syncTodos}
131
+ onValueChange={(syncTodos) => change({ syncTodos })}
132
+ />
133
+ </SettingsCard>
134
+ </SettingsSection>
135
+ <SaveBar
136
+ theme={theme}
137
+ dirty={dirty}
138
+ saving={settings.saving}
139
+ error={settings.saveError}
140
+ onSave={() => void save()}
141
+ onDiscard={discard}
142
+ />
143
+ </View>
144
+ );
145
+ }
@@ -21,6 +21,8 @@ import {
21
21
  upsertProject,
22
22
  } from "../shared/settings";
23
23
  import { effectiveKeyScope, KeyScopeProvider } from "./key-scope";
24
+ import { ProjectSetup } from "./project-init";
25
+ import { AccessSection } from "./settings-access";
24
26
  import { type PaseoProjectOption, useAuthStatus, useCatalog, useProjects } from "./queries";
25
27
  import {
26
28
  MultilineField,
@@ -120,6 +122,12 @@ function ProjectEditor({ theme, settings }: { theme: Theme; settings: ReadySetti
120
122
  }
121
123
  return (
122
124
  <View>
125
+ <AccessSection
126
+ access={values.access}
127
+ saved={settings.values.access}
128
+ projects={projects.data}
129
+ edit={edit}
130
+ />
123
131
  <SettingsSection title="Project">
124
132
  <SettingsCard>
125
133
  <SettingsSelect
@@ -132,6 +140,7 @@ function ProjectEditor({ theme, settings }: { theme: Theme; settings: ReadySetti
132
140
  </SettingsCard>
133
141
  </SettingsSection>
134
142
  <TeamsSection config={config} change={change} />
143
+ <ProjectSetup theme={theme} config={config} change={change} />
135
144
  <InstructionsSection theme={theme} config={config} change={change} />
136
145
  {AGENT_ACTIONS.map((action) => (
137
146
  <ActionSection key={action} theme={theme} action={action} config={config} change={change} />
@@ -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.client.tsx CHANGED
@@ -1,7 +1,11 @@
1
1
  import type { PluginClientContext } from "@getpaseo/plugin/client";
2
2
  import { LinearPanel, LinearScreen } from "./client/browser";
3
3
  import { openPluginScreen, registerScreen } from "./client/compat";
4
+ import { gateAgentPanel } from "./client/access";
5
+ import { LivePanel } from "./client/live-panel";
6
+ import { contributeComposerPills } from "./client/pill";
4
7
  import { ConnectionSettings } from "./client/settings-connection";
8
+ import { LiveSettings } from "./client/settings-live";
5
9
  import { ProjectSettings } from "./client/settings-projects";
6
10
  import { PromptSettings } from "./client/settings-prompts";
7
11
  import { focusIssue, panelScope, SCREEN_SCOPE, updateBrowser } from "./client/store";
@@ -11,6 +15,7 @@ import { ISSUE_CARD_KIND, IssueCardSchema, LINEAR_IDENTIFIER } from "./shared/li
11
15
 
12
16
  const SCREEN_ID = "linear";
13
17
  const PANEL_ID = "issues";
18
+ const LIVE_PANEL_ID = "live";
14
19
 
15
20
  export default function contribute(client: PluginClientContext) {
16
21
  client.addAttachmentSource(issueAttachments);
@@ -40,6 +45,19 @@ export default function contribute(client: PluginClientContext) {
40
45
  icon: "MessageSquare",
41
46
  Component: PromptSettings,
42
47
  });
48
+ client.addWorkspacePanel({
49
+ id: LIVE_PANEL_ID,
50
+ title: "Linear Live",
51
+ icon: "Radar",
52
+ context: "agent",
53
+ Component: gateAgentPanel(LivePanel),
54
+ });
55
+ client.addSettingsScreen({
56
+ id: "live",
57
+ title: "Live",
58
+ icon: "Radar",
59
+ Component: LiveSettings,
60
+ });
43
61
  client.addSettingsScreen({
44
62
  id: "projects",
45
63
  title: "Projects",
@@ -77,6 +95,26 @@ export default function contribute(client: PluginClientContext) {
77
95
  openPanel(PANEL_ID);
78
96
  },
79
97
  });
98
+ client.addCommandCenterItem({
99
+ id: "open-live",
100
+ title: "Linear: Open Linear Live",
101
+ icon: "Radar",
102
+ keywords: ["linear", "live", "progress", "map"],
103
+ context: "agent",
104
+ onSelect({ openPanel }) {
105
+ openPanel(LIVE_PANEL_ID);
106
+ },
107
+ });
108
+ client.addCommandCenterItem({
109
+ id: "setup-project",
110
+ title: "Linear: Set up project prompts",
111
+ icon: "Sparkles",
112
+ keywords: ["linear", "init", "setup", "agents.md", "guidelines"],
113
+ context: "global",
114
+ onSelect({ openSettings }) {
115
+ openSettings("projects");
116
+ },
117
+ });
80
118
  client.addCommandCenterItem({
81
119
  id: "settings",
82
120
  title: "Linear: Settings",
@@ -107,5 +145,5 @@ export default function contribute(client: PluginClientContext) {
107
145
  schema: IssueCardSchema,
108
146
  Component: IssueCardRow,
109
147
  });
110
- return () => {};
148
+ return contributeComposerPills(client);
111
149
  }
package/index.server.ts CHANGED
@@ -1,12 +1,36 @@
1
+ import path from "node:path";
1
2
  import type { PluginServerContext } from "@getpaseo/plugin/server";
3
+ import { registerAgentHooks } from "./server/agent-hooks";
4
+ import { registerAgentTools } from "./server/agent-tools";
2
5
  import { createCredentialStore, defaultCredentialPath } from "./server/credentials";
3
6
  import { registerHandlers } from "./server/handlers";
4
- import { linearSettings } from "./shared/settings";
7
+ import { registerInit } from "./server/init";
8
+ import { registerLive } from "./server/live";
9
+ import { createLaunchQueue, createMapStore } from "./server/stores";
10
+ import { registerSync } from "./server/sync";
11
+ import { accessRpc, type LinearSettings, linearSettings } from "./shared/settings";
5
12
 
6
13
  export default function contribute(server: PluginServerContext) {
7
- server.registerSettings(linearSettings);
8
- registerHandlers(server, {
9
- credentials: createCredentialStore({ file: defaultCredentialPath() }),
14
+ const settings = server.registerSettings(linearSettings);
15
+ const readSettings = async (): Promise<LinearSettings | null> => {
16
+ const state = await settings.read();
17
+ return state.status === "ready" ? state.values : null;
18
+ };
19
+ const credentialPath = defaultCredentialPath();
20
+ const access = registerHandlers(server, {
21
+ credentials: createCredentialStore({ file: credentialPath }),
10
22
  });
11
- return () => {};
23
+ const dataDirectory = path.dirname(credentialPath);
24
+ const maps = createMapStore(path.join(dataDirectory, "live-maps.json"));
25
+ const launches = createLaunchQueue(path.join(dataDirectory, "pending-launches.json"));
26
+ registerLive(server, { access, maps, launches, readSettings });
27
+ registerSync(server, { access, readSettings });
28
+ registerInit(server, { readSettings });
29
+ registerAgentHooks(server, dataDirectory);
30
+ const closeTools = registerAgentTools(server, { access, dataDirectory });
31
+ server.handle(accessRpc, async () => {
32
+ const values = await readSettings();
33
+ return values?.access ?? linearSettings.schema.parse({}).access;
34
+ });
35
+ return closeTools;
12
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yannelli/paseo-linear-plugin",
3
- "version": "1.0.1",
3
+ "version": "1.1.0-beta.1",
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
+ }