@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,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 {
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
+
}
|