@yannelli/paseo-linear-plugin 1.0.1 → 1.1.0-alpha.0
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 +32 -2
- package/README.md +134 -8
- package/client/access.tsx +67 -0
- package/client/browser.tsx +12 -9
- package/client/compat.ts +23 -5
- package/client/connect.tsx +3 -2
- package/client/launch-assignments.tsx +182 -0
- package/client/launch-choices.ts +21 -6
- package/client/launch-guidance.tsx +103 -0
- package/client/launch-plan.ts +114 -17
- package/client/launch.tsx +55 -9
- package/client/live-agents.tsx +446 -0
- package/client/live-feed.tsx +151 -0
- package/client/live-header.tsx +310 -0
- package/client/live-issues.tsx +345 -0
- package/client/live-map.tsx +351 -0
- package/client/live-panel.tsx +392 -0
- package/client/live-timeline.ts +379 -0
- package/client/pill.tsx +394 -0
- package/client/plugin-icon.tsx +75 -0
- package/client/project-init.tsx +192 -0
- package/client/project-knowledge.tsx +87 -0
- package/client/settings-access.tsx +56 -0
- package/client/settings-agents.tsx +113 -0
- package/client/settings-fields.tsx +11 -3
- package/client/settings-icon.tsx +277 -0
- package/client/settings-live.tsx +128 -0
- package/client/settings-overrides.tsx +148 -0
- package/client/settings-projects.tsx +26 -4
- package/client/settings-prompts.tsx +3 -3
- package/client/timeline-card.tsx +2 -1
- package/client/tool-buttons.tsx +68 -0
- package/client/ui.tsx +16 -7
- package/client/web.ts +81 -0
- package/index.client.tsx +160 -51
- package/index.server.ts +32 -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 +276 -0
- package/server/handlers.ts +12 -1
- package/server/init.ts +89 -0
- package/server/inspect.ts +392 -0
- package/server/knowledge.ts +157 -0
- package/server/live.ts +371 -0
- package/server/mcp-http.ts +156 -0
- package/server/stores.ts +126 -0
- package/server/subagent-logs.ts +146 -0
- package/server/sync.ts +133 -0
- package/shared/activity.ts +268 -0
- package/shared/agent-hooks.ts +68 -0
- package/shared/agent-tools.ts +157 -0
- package/shared/custom-icon.ts +188 -0
- package/shared/knowledge.ts +255 -0
- package/shared/live.ts +248 -0
- package/shared/map-model.ts +240 -0
- package/shared/project-setup.ts +56 -0
- package/shared/prompts.ts +109 -2
- package/shared/settings.ts +145 -3
- 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
package/index.client.tsx
CHANGED
|
@@ -1,33 +1,160 @@
|
|
|
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";
|
|
7
|
+
import { onMenuIconChange, PluginIcon, startMenuIcon } from "./client/plugin-icon";
|
|
8
|
+
import { AgentSettings } from "./client/settings-agents";
|
|
4
9
|
import { ConnectionSettings } from "./client/settings-connection";
|
|
10
|
+
import { IconSettingsScreen } from "./client/settings-icon";
|
|
11
|
+
import { LiveSettings } from "./client/settings-live";
|
|
5
12
|
import { ProjectSettings } from "./client/settings-projects";
|
|
6
13
|
import { PromptSettings } from "./client/settings-prompts";
|
|
7
14
|
import { focusIssue, panelScope, SCREEN_SCOPE, updateBrowser } from "./client/store";
|
|
8
15
|
import { IssueCardRow } from "./client/timeline-card";
|
|
16
|
+
import { BUILT_IN_ICON } from "./shared/custom-icon";
|
|
9
17
|
import { issueAttachments } from "./shared/issues";
|
|
10
18
|
import { ISSUE_CARD_KIND, IssueCardSchema, LINEAR_IDENTIFIER } from "./shared/linear";
|
|
11
19
|
|
|
12
20
|
const SCREEN_ID = "linear";
|
|
13
21
|
const PANEL_ID = "issues";
|
|
22
|
+
const LIVE_PANEL_ID = "live";
|
|
23
|
+
|
|
24
|
+
type Cleanup = () => void;
|
|
25
|
+
|
|
26
|
+
// Panels and Command Center items take the menu icon when they register, so a new choice
|
|
27
|
+
// registers the whole group again. One group keeps their order the same.
|
|
28
|
+
function contributeMenus(client: PluginClientContext, icon: string): Cleanup {
|
|
29
|
+
const cleanups: Cleanup[] = [];
|
|
30
|
+
const removeAll = () => {
|
|
31
|
+
for (const cleanup of cleanups.splice(0).reverse()) cleanup();
|
|
32
|
+
};
|
|
33
|
+
try {
|
|
34
|
+
cleanups.push(
|
|
35
|
+
client.addWorkspacePanel({
|
|
36
|
+
id: PANEL_ID,
|
|
37
|
+
title: "Linear",
|
|
38
|
+
icon,
|
|
39
|
+
context: "workspace",
|
|
40
|
+
locations: ["workspace", "explorer"],
|
|
41
|
+
Component: LinearPanel,
|
|
42
|
+
}),
|
|
43
|
+
client.addWorkspacePanel({
|
|
44
|
+
id: LIVE_PANEL_ID,
|
|
45
|
+
title: "Linear Live",
|
|
46
|
+
icon: "Radar",
|
|
47
|
+
context: "agent",
|
|
48
|
+
Component: gateAgentPanel(LivePanel),
|
|
49
|
+
}),
|
|
50
|
+
client.addCommandCenterItem({
|
|
51
|
+
id: "open-issues",
|
|
52
|
+
title: "Linear: Open issues",
|
|
53
|
+
icon,
|
|
54
|
+
keywords: ["linear", "issues", "tickets"],
|
|
55
|
+
context: "global",
|
|
56
|
+
onSelect(context) {
|
|
57
|
+
openPluginScreen(context, SCREEN_ID);
|
|
58
|
+
},
|
|
59
|
+
}),
|
|
60
|
+
client.addCommandCenterItem({
|
|
61
|
+
id: "create-issue",
|
|
62
|
+
title: "Linear: Create issue",
|
|
63
|
+
icon: "Plus",
|
|
64
|
+
keywords: ["linear", "new", "ticket"],
|
|
65
|
+
context: "global",
|
|
66
|
+
onSelect(context) {
|
|
67
|
+
updateBrowser(SCREEN_SCOPE, { creating: true });
|
|
68
|
+
openPluginScreen(context, SCREEN_ID);
|
|
69
|
+
},
|
|
70
|
+
}),
|
|
71
|
+
client.addCommandCenterItem({
|
|
72
|
+
id: "workspace-issues",
|
|
73
|
+
title: "Linear: Show issues in this workspace",
|
|
74
|
+
icon,
|
|
75
|
+
keywords: ["linear", "panel"],
|
|
76
|
+
context: "workspace",
|
|
77
|
+
onSelect({ openPanel }) {
|
|
78
|
+
openPanel(PANEL_ID);
|
|
79
|
+
},
|
|
80
|
+
}),
|
|
81
|
+
client.addCommandCenterItem({
|
|
82
|
+
id: "open-live",
|
|
83
|
+
title: "Linear: Open Linear Live",
|
|
84
|
+
icon: "Radar",
|
|
85
|
+
keywords: ["linear", "live", "progress", "map"],
|
|
86
|
+
context: "agent",
|
|
87
|
+
onSelect({ openPanel }) {
|
|
88
|
+
openPanel(LIVE_PANEL_ID);
|
|
89
|
+
},
|
|
90
|
+
}),
|
|
91
|
+
client.addCommandCenterItem({
|
|
92
|
+
id: "setup-project",
|
|
93
|
+
title: "Linear: Set up project prompts",
|
|
94
|
+
icon: "Sparkles",
|
|
95
|
+
keywords: ["linear", "init", "setup", "agents.md", "guidelines"],
|
|
96
|
+
context: "global",
|
|
97
|
+
onSelect({ openSettings }) {
|
|
98
|
+
openSettings("projects");
|
|
99
|
+
},
|
|
100
|
+
}),
|
|
101
|
+
client.addCommandCenterItem({
|
|
102
|
+
id: "settings",
|
|
103
|
+
title: "Linear: Settings",
|
|
104
|
+
icon: "Settings",
|
|
105
|
+
keywords: ["linear", "api key", "prompts", "projects", "agents", "mcp", "tools"],
|
|
106
|
+
context: "global",
|
|
107
|
+
onSelect({ openSettings }) {
|
|
108
|
+
openSettings("connection");
|
|
109
|
+
},
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
removeAll();
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
return removeAll;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Registers the menus with the chosen icon, and again when it changes. */
|
|
120
|
+
function contributeMenusWithIcon(client: PluginClientContext): Cleanup {
|
|
121
|
+
let icon = startMenuIcon();
|
|
122
|
+
let removeMenus: Cleanup;
|
|
123
|
+
try {
|
|
124
|
+
removeMenus = contributeMenus(client, icon);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
console.error("Linear menu icon", error);
|
|
127
|
+
icon = BUILT_IN_ICON;
|
|
128
|
+
removeMenus = contributeMenus(client, icon);
|
|
129
|
+
}
|
|
130
|
+
const stop = onMenuIconChange((next) => {
|
|
131
|
+
if (next === icon) return;
|
|
132
|
+
removeMenus();
|
|
133
|
+
try {
|
|
134
|
+
removeMenus = contributeMenus(client, next);
|
|
135
|
+
icon = next;
|
|
136
|
+
} catch (error) {
|
|
137
|
+
// An icon this Paseo does not have: keep the built-in one.
|
|
138
|
+
console.error("Linear menu icon", error);
|
|
139
|
+
removeMenus = contributeMenus(client, BUILT_IN_ICON);
|
|
140
|
+
icon = BUILT_IN_ICON;
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
return () => {
|
|
144
|
+
stop();
|
|
145
|
+
removeMenus();
|
|
146
|
+
};
|
|
147
|
+
}
|
|
14
148
|
|
|
15
149
|
export default function contribute(client: PluginClientContext) {
|
|
16
150
|
client.addAttachmentSource(issueAttachments);
|
|
17
151
|
registerScreen(client, {
|
|
18
152
|
id: SCREEN_ID,
|
|
19
153
|
title: "Linear",
|
|
20
|
-
icon:
|
|
154
|
+
icon: BUILT_IN_ICON,
|
|
155
|
+
RowIcon: PluginIcon,
|
|
21
156
|
Component: LinearScreen,
|
|
22
157
|
});
|
|
23
|
-
client.addWorkspacePanel({
|
|
24
|
-
id: PANEL_ID,
|
|
25
|
-
title: "Linear",
|
|
26
|
-
icon: "SquareKanban",
|
|
27
|
-
context: "workspace",
|
|
28
|
-
locations: ["workspace", "explorer"],
|
|
29
|
-
Component: LinearPanel,
|
|
30
|
-
});
|
|
31
158
|
client.addSettingsScreen({
|
|
32
159
|
id: "connection",
|
|
33
160
|
title: "Account",
|
|
@@ -40,53 +167,31 @@ export default function contribute(client: PluginClientContext) {
|
|
|
40
167
|
icon: "MessageSquare",
|
|
41
168
|
Component: PromptSettings,
|
|
42
169
|
});
|
|
170
|
+
client.addSettingsScreen({
|
|
171
|
+
id: "agents",
|
|
172
|
+
title: "Agents",
|
|
173
|
+
icon: "Bot",
|
|
174
|
+
Component: AgentSettings,
|
|
175
|
+
});
|
|
176
|
+
client.addSettingsScreen({
|
|
177
|
+
id: "live",
|
|
178
|
+
title: "Live",
|
|
179
|
+
icon: "Radar",
|
|
180
|
+
Component: LiveSettings,
|
|
181
|
+
});
|
|
182
|
+
client.addSettingsScreen({
|
|
183
|
+
id: "icon",
|
|
184
|
+
title: "Icon",
|
|
185
|
+
icon: "Shapes",
|
|
186
|
+
Component: IconSettingsScreen,
|
|
187
|
+
});
|
|
43
188
|
client.addSettingsScreen({
|
|
44
189
|
id: "projects",
|
|
45
190
|
title: "Projects",
|
|
46
191
|
icon: "FolderGit2",
|
|
47
192
|
Component: ProjectSettings,
|
|
48
193
|
});
|
|
49
|
-
client
|
|
50
|
-
id: "open-issues",
|
|
51
|
-
title: "Linear: Open issues",
|
|
52
|
-
icon: "SquareKanban",
|
|
53
|
-
keywords: ["linear", "issues", "tickets"],
|
|
54
|
-
context: "global",
|
|
55
|
-
onSelect(context) {
|
|
56
|
-
openPluginScreen(context, SCREEN_ID);
|
|
57
|
-
},
|
|
58
|
-
});
|
|
59
|
-
client.addCommandCenterItem({
|
|
60
|
-
id: "create-issue",
|
|
61
|
-
title: "Linear: Create issue",
|
|
62
|
-
icon: "Plus",
|
|
63
|
-
keywords: ["linear", "new", "ticket"],
|
|
64
|
-
context: "global",
|
|
65
|
-
onSelect(context) {
|
|
66
|
-
updateBrowser(SCREEN_SCOPE, { creating: true });
|
|
67
|
-
openPluginScreen(context, SCREEN_ID);
|
|
68
|
-
},
|
|
69
|
-
});
|
|
70
|
-
client.addCommandCenterItem({
|
|
71
|
-
id: "workspace-issues",
|
|
72
|
-
title: "Linear: Show issues in this workspace",
|
|
73
|
-
icon: "SquareKanban",
|
|
74
|
-
keywords: ["linear", "panel"],
|
|
75
|
-
context: "workspace",
|
|
76
|
-
onSelect({ openPanel }) {
|
|
77
|
-
openPanel(PANEL_ID);
|
|
78
|
-
},
|
|
79
|
-
});
|
|
80
|
-
client.addCommandCenterItem({
|
|
81
|
-
id: "settings",
|
|
82
|
-
title: "Linear: Settings",
|
|
83
|
-
icon: "Settings",
|
|
84
|
-
keywords: ["linear", "api key", "prompts", "projects"],
|
|
85
|
-
context: "global",
|
|
86
|
-
onSelect({ openSettings }) {
|
|
87
|
-
openSettings("connection");
|
|
88
|
-
},
|
|
89
|
-
});
|
|
194
|
+
const removeMenus = contributeMenusWithIcon(client);
|
|
90
195
|
client.addSlashCommand({
|
|
91
196
|
name: "linear",
|
|
92
197
|
description: "Open Linear issues, or jump to one by key",
|
|
@@ -107,5 +212,9 @@ export default function contribute(client: PluginClientContext) {
|
|
|
107
212
|
schema: IssueCardSchema,
|
|
108
213
|
Component: IssueCardRow,
|
|
109
214
|
});
|
|
110
|
-
|
|
215
|
+
const removePills = contributeComposerPills(client);
|
|
216
|
+
return () => {
|
|
217
|
+
removeMenus();
|
|
218
|
+
removePills();
|
|
219
|
+
};
|
|
111
220
|
}
|
package/index.server.ts
CHANGED
|
@@ -1,12 +1,39 @@
|
|
|
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 { createKnowledge, registerKnowledge } from "./server/knowledge";
|
|
9
|
+
import { registerLive } from "./server/live";
|
|
10
|
+
import { createLaunchQueue, createMapStore } from "./server/stores";
|
|
11
|
+
import { registerSync } from "./server/sync";
|
|
12
|
+
import { accessRpc, type LinearSettings, linearSettings } from "./shared/settings";
|
|
5
13
|
|
|
6
14
|
export default function contribute(server: PluginServerContext) {
|
|
7
|
-
server.registerSettings(linearSettings);
|
|
8
|
-
|
|
9
|
-
|
|
15
|
+
const settings = server.registerSettings(linearSettings);
|
|
16
|
+
const readSettings = async (): Promise<LinearSettings | null> => {
|
|
17
|
+
const state = await settings.read();
|
|
18
|
+
return state.status === "ready" ? state.values : null;
|
|
19
|
+
};
|
|
20
|
+
const credentialPath = defaultCredentialPath();
|
|
21
|
+
const access = registerHandlers(server, {
|
|
22
|
+
credentials: createCredentialStore({ file: credentialPath }),
|
|
10
23
|
});
|
|
11
|
-
|
|
24
|
+
const dataDirectory = path.dirname(credentialPath);
|
|
25
|
+
const maps = createMapStore(path.join(dataDirectory, "live-maps.json"));
|
|
26
|
+
const launches = createLaunchQueue(path.join(dataDirectory, "pending-launches.json"));
|
|
27
|
+
const knowledge = createKnowledge(path.join(dataDirectory, "knowledge"));
|
|
28
|
+
registerKnowledge(server, knowledge);
|
|
29
|
+
registerLive(server, { access, maps, launches, knowledge, readSettings });
|
|
30
|
+
registerSync(server, { access, readSettings });
|
|
31
|
+
registerInit(server, { readSettings, knowledge });
|
|
32
|
+
registerAgentHooks(server, dataDirectory);
|
|
33
|
+
const closeTools = registerAgentTools(server, { access, dataDirectory, readSettings });
|
|
34
|
+
server.handle(accessRpc, async () => {
|
|
35
|
+
const values = await readSettings();
|
|
36
|
+
return values?.access ?? linearSettings.schema.parse({}).access;
|
|
37
|
+
});
|
|
38
|
+
return closeTools;
|
|
12
39
|
}
|
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
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { PluginHandlerContext, PluginLifecycleEvents } from "@getpaseo/plugin/server";
|
|
3
|
+
import { type ZodType, z } from "zod";
|
|
4
|
+
import { relativePath } from "../shared/activity";
|
|
5
|
+
import type { Job } from "../shared/live";
|
|
6
|
+
import { lastJsonObject } from "../shared/live";
|
|
7
|
+
|
|
8
|
+
// Explore and project setup run a read-only agent, wait for it in the background, and read a
|
|
9
|
+
// JSON answer from its last message. Claude ignores outputSchema, so the prompt asks for JSON.
|
|
10
|
+
|
|
11
|
+
export type Paseo = PluginHandlerContext["paseo"];
|
|
12
|
+
type WorkspaceHandle = Awaited<ReturnType<Paseo["workspaces"]["open"]>>;
|
|
13
|
+
type AgentHandle = ReturnType<Paseo["agents"]["ref"]>;
|
|
14
|
+
export type PermissionRequest = PluginLifecycleEvents["agent.permission_requested"]["request"];
|
|
15
|
+
export type PermissionAnswer = Parameters<AgentHandle["respondToPermission"]>[0]["response"];
|
|
16
|
+
|
|
17
|
+
export interface InternalRun<T> {
|
|
18
|
+
paseo: Paseo;
|
|
19
|
+
workspace: WorkspaceHandle;
|
|
20
|
+
/** Folder the agent works in; its reads must stay inside. */
|
|
21
|
+
cwd: string;
|
|
22
|
+
provider: string;
|
|
23
|
+
/** Thinking option for the model in `provider`; ignored when another model runs. */
|
|
24
|
+
effort?: string;
|
|
25
|
+
title: string;
|
|
26
|
+
prompt: string;
|
|
27
|
+
labels: Record<string, string>;
|
|
28
|
+
schema: ZodType<T>;
|
|
29
|
+
timeoutMs: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface SnapshotEntry {
|
|
33
|
+
provider: string;
|
|
34
|
+
status: string;
|
|
35
|
+
enabled?: boolean;
|
|
36
|
+
modes?: readonly { id: string; label?: string }[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const SAFE_MODES = [/read.?only/i, /^default$/i, /ask/i];
|
|
40
|
+
const UNSAFE_MODE = /bypass|full|yolo|danger/i;
|
|
41
|
+
|
|
42
|
+
// Never inherit a provider default that skips approvals. Claude's "default" asks before
|
|
43
|
+
// edits, so an edit attempt stops the run. Codex has no read-only mode; "auto" asks on request.
|
|
44
|
+
export function safeModeId(modes: SnapshotEntry["modes"]): string | undefined {
|
|
45
|
+
for (const pattern of SAFE_MODES) {
|
|
46
|
+
const match = modes?.find((mode) => pattern.test(mode.id));
|
|
47
|
+
if (match) return match.id;
|
|
48
|
+
}
|
|
49
|
+
return modes?.find((mode) => !UNSAFE_MODE.test(`${mode.id} ${mode.label ?? ""}`))?.id;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const UNSUPPORTED_KEYWORDS = new Set([
|
|
53
|
+
"$schema",
|
|
54
|
+
"default",
|
|
55
|
+
"minLength",
|
|
56
|
+
"maxLength",
|
|
57
|
+
"minItems",
|
|
58
|
+
"maxItems",
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
/** JSON schema for strict structured output: every property required, no extras. */
|
|
62
|
+
export function strictJsonSchema(schema: ZodType): Record<string, unknown> {
|
|
63
|
+
const strip = (node: unknown): unknown => {
|
|
64
|
+
if (Array.isArray(node)) return node.map(strip);
|
|
65
|
+
if (!node || typeof node !== "object") return node;
|
|
66
|
+
return Object.fromEntries(
|
|
67
|
+
Object.entries(node)
|
|
68
|
+
.filter(([key]) => !UNSUPPORTED_KEYWORDS.has(key))
|
|
69
|
+
.map(([key, value]) => [key, strip(value)]),
|
|
70
|
+
);
|
|
71
|
+
};
|
|
72
|
+
return strip(z.toJSONSchema(schema, { io: "output" })) as Record<string, unknown>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Some Claude builds search only through the shell, so internal agents may run a short list of
|
|
76
|
+
// commands that read. Anything that writes, runs other programs, or leaves the folder is denied.
|
|
77
|
+
const READ_COMMAND = /^(?:ls|find|rg|grep|cat|head|tail|wc|pwd|git (?:ls-files|grep))(?:\s|$)/;
|
|
78
|
+
const UNSAFE_SHELL =
|
|
79
|
+
/[`;&<>~$\n\r]|\.\.|\s-(?:exec|execdir|ok|okdir|delete|fprint\w*|fls|O)\b|\s--(?:pre|open-files-in-pager)\b/;
|
|
80
|
+
/** Quoted text the shell does not expand: single quotes, or double quotes without $ or `. */
|
|
81
|
+
const LITERAL = /'[^']*'|"[^"$`\\]*"/g;
|
|
82
|
+
|
|
83
|
+
function insideFolder(filePath: string, cwd: string): boolean {
|
|
84
|
+
return filePath.replace(/\/+$/, "") === cwd.replace(/\/+$/, "") || relativePath(filePath, cwd) !== null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function readOnlyCommand(command: string, cwd: string): boolean {
|
|
88
|
+
const text = command.replace(/\s2>\s*\/dev\/null/g, " ").trim();
|
|
89
|
+
const bare = text.replace(LITERAL, "Q");
|
|
90
|
+
if (!bare || UNSAFE_SHELL.test(bare)) return false;
|
|
91
|
+
const outside = text.split(/\s+|=/).some((word) => {
|
|
92
|
+
const filePath = word.replace(/['"]/g, "");
|
|
93
|
+
return filePath.startsWith("/") && !insideFolder(filePath, cwd);
|
|
94
|
+
});
|
|
95
|
+
return !outside && bare.split("|").every((part) => READ_COMMAND.test(part.trim()));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Prompt lines that match what readOnlyAnswer allows. */
|
|
99
|
+
export const READ_ONLY_RULES = [
|
|
100
|
+
"Read and search only. Do not create, edit, or delete files.",
|
|
101
|
+
"To find files, use your read and search tools, or these shell commands, one at a time and",
|
|
102
|
+
"without redirection: ls, find, rg, grep, cat, head, tail, wc, git ls-files, git grep.",
|
|
103
|
+
"Keep paths inside this folder. Other commands are denied.",
|
|
104
|
+
"Work alone: do not start subagents or background tasks.",
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
const DENIED =
|
|
108
|
+
"Not allowed in this read-only run. Read and search files in this repository only, one " +
|
|
109
|
+
"command at a time, without redirection.";
|
|
110
|
+
|
|
111
|
+
/** No one watches internal agents, so the plugin answers their prompts: reads yes, else no. */
|
|
112
|
+
export function readOnlyAnswer(request: PermissionRequest, cwd: string): PermissionAnswer {
|
|
113
|
+
if (request.kind === "question") {
|
|
114
|
+
return { behavior: "deny", message: "No one can answer here. Decide yourself and continue." };
|
|
115
|
+
}
|
|
116
|
+
const detail = request.detail;
|
|
117
|
+
const command =
|
|
118
|
+
detail?.type === "shell" ? detail.command : (request.input as { command?: unknown })?.command;
|
|
119
|
+
const allowed =
|
|
120
|
+
request.kind === "tool" &&
|
|
121
|
+
((detail?.type === "read" && insideFolder(detail.filePath, cwd)) ||
|
|
122
|
+
(detail?.type === "search" && detail.toolName !== "web_search") ||
|
|
123
|
+
(typeof command === "string" && readOnlyCommand(command, cwd)));
|
|
124
|
+
return allowed ? { behavior: "allow" } : { behavior: "deny", message: DENIED };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function entriesOf(snapshot: unknown): readonly SnapshotEntry[] {
|
|
128
|
+
const value = snapshot as {
|
|
129
|
+
entries?: readonly SnapshotEntry[];
|
|
130
|
+
compactSnapshot?: { entries?: readonly SnapshotEntry[] } | null;
|
|
131
|
+
};
|
|
132
|
+
const entries = value.entries ?? [];
|
|
133
|
+
return entries.length > 0 ? entries : (value.compactSnapshot?.entries ?? []);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Uses `preferred` (`provider` or `provider/model`) when ready, else the first ready provider. */
|
|
137
|
+
export async function agentConfigFor(paseo: Paseo, preferred: string) {
|
|
138
|
+
const entries = entriesOf(await paseo.providers.snapshot()).filter(
|
|
139
|
+
(entry) => entry.status === "ready" && entry.enabled !== false,
|
|
140
|
+
);
|
|
141
|
+
const wanted = preferred.split("/")[0] ?? "";
|
|
142
|
+
const entry = entries.find((candidate) => candidate.provider === wanted) ?? entries[0];
|
|
143
|
+
if (!entry) throw new Error("No agent provider is ready on this host");
|
|
144
|
+
const provider = entry.provider === wanted && preferred.includes("/") ? preferred : entry.provider;
|
|
145
|
+
const modeId = safeModeId(entry.modes);
|
|
146
|
+
return { provider, ...(modeId ? { modeId } : {}) };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function lastReply(handle: AgentHandle) {
|
|
150
|
+
const page = await handle.timeline.refetch({
|
|
151
|
+
direction: "tail",
|
|
152
|
+
limit: 60,
|
|
153
|
+
projection: "projected",
|
|
154
|
+
});
|
|
155
|
+
for (const entry of [...page.entries].reverse()) {
|
|
156
|
+
const item = entry.item as { type: string; text?: unknown };
|
|
157
|
+
if (item.type === "assistant_message" && typeof item.text === "string") return item.text;
|
|
158
|
+
}
|
|
159
|
+
return "";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const MAX_STALLS = 20;
|
|
163
|
+
const STALL_DELAY_MS = 500;
|
|
164
|
+
|
|
165
|
+
// waitForFinish also returns when the agent asks for permission, so answer and keep waiting.
|
|
166
|
+
// A prompt is answered once; while the daemon applies an answer, the loop waits briefly.
|
|
167
|
+
export async function waitUntilIdle(handle: AgentHandle, cwd: string, timeoutMs: number) {
|
|
168
|
+
const deadline = Date.now() + timeoutMs;
|
|
169
|
+
const answered = new Set<string>();
|
|
170
|
+
let stalls = 0;
|
|
171
|
+
for (;;) {
|
|
172
|
+
const remaining = deadline - Date.now();
|
|
173
|
+
if (remaining <= 0) throw new Error("The agent did not finish in time");
|
|
174
|
+
const finish = await handle.waitForFinish(remaining);
|
|
175
|
+
if (finish.status === "idle") return;
|
|
176
|
+
if (finish.status !== "permission") {
|
|
177
|
+
throw new Error(finish.error ?? `The agent stopped with status ${finish.status}`);
|
|
178
|
+
}
|
|
179
|
+
const unanswered = (requests: readonly PermissionRequest[] | null | undefined) =>
|
|
180
|
+
(requests ?? []).filter((request) => !answered.has(request.id));
|
|
181
|
+
let pending = unanswered(finish.final?.pendingPermissions);
|
|
182
|
+
if (pending.length === 0) {
|
|
183
|
+
await handle.refresh();
|
|
184
|
+
pending = unanswered(handle.pendingPermissions);
|
|
185
|
+
}
|
|
186
|
+
if (pending.length === 0) {
|
|
187
|
+
stalls += 1;
|
|
188
|
+
if (answered.size === 0 || stalls > MAX_STALLS) {
|
|
189
|
+
throw new Error("The agent is waiting for input");
|
|
190
|
+
}
|
|
191
|
+
await new Promise((resolve) => setTimeout(resolve, STALL_DELAY_MS));
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
stalls = 0;
|
|
195
|
+
for (const request of pending) {
|
|
196
|
+
answered.add(request.id);
|
|
197
|
+
await handle
|
|
198
|
+
.respondToPermission({ requestId: request.id, response: readOnlyAnswer(request, cwd) })
|
|
199
|
+
.catch((error: unknown) => console.error("Linear could not answer a permission", error));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Waits for an internal agent, reads its JSON answer, and archives it either way. */
|
|
205
|
+
export async function finishInternalRun<T>(
|
|
206
|
+
handle: AgentHandle,
|
|
207
|
+
cwd: string,
|
|
208
|
+
schema: ZodType<T>,
|
|
209
|
+
timeoutMs: number,
|
|
210
|
+
): Promise<T> {
|
|
211
|
+
try {
|
|
212
|
+
await waitUntilIdle(handle, cwd, timeoutMs);
|
|
213
|
+
const parsed = schema.safeParse(lastJsonObject(await lastReply(handle)));
|
|
214
|
+
if (!parsed.success) throw new Error("The agent did not return the expected JSON");
|
|
215
|
+
return parsed.data;
|
|
216
|
+
} finally {
|
|
217
|
+
await handle.archive().catch(() => undefined);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export interface StartedRun<T> {
|
|
222
|
+
agentId: string;
|
|
223
|
+
result: Promise<T>;
|
|
224
|
+
}
|
|
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
|
+
|
|
232
|
+
export async function startInternalRun<T>(run: InternalRun<T>): Promise<StartedRun<T>> {
|
|
233
|
+
const config = withEffort(await agentConfigFor(run.paseo, run.provider), run.provider, run.effort);
|
|
234
|
+
const handle = await run.workspace.agents.create({
|
|
235
|
+
config,
|
|
236
|
+
title: run.title,
|
|
237
|
+
prompt: run.prompt,
|
|
238
|
+
labels: run.labels,
|
|
239
|
+
outputSchema: strictJsonSchema(run.schema),
|
|
240
|
+
});
|
|
241
|
+
const result = finishInternalRun(handle, run.cwd, run.schema, run.timeoutMs);
|
|
242
|
+
return { agentId: handle.id, result };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export interface JobStore<T> {
|
|
246
|
+
get(id: string): { job: Job; value: T | null } | null;
|
|
247
|
+
start(id: string | null, begin: () => Promise<StartedRun<T>>): Promise<Job>;
|
|
248
|
+
/** Resolves with the job once it is done or failed; null for an unknown id. */
|
|
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;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
interface JobRecord<T> {
|
|
255
|
+
job: Job;
|
|
256
|
+
value: T | null;
|
|
257
|
+
settled: Promise<void>;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function createJobStore<T>(): JobStore<T> {
|
|
261
|
+
const jobs = new Map<string, JobRecord<T>>();
|
|
262
|
+
return {
|
|
263
|
+
get: (id) => {
|
|
264
|
+
const record = jobs.get(id);
|
|
265
|
+
return record ? { job: record.job, value: record.value } : null;
|
|
266
|
+
},
|
|
267
|
+
forget(id) {
|
|
268
|
+
if (jobs.get(id)?.job.status !== "running") jobs.delete(id);
|
|
269
|
+
},
|
|
270
|
+
async wait(id) {
|
|
271
|
+
const record = jobs.get(id);
|
|
272
|
+
if (!record) return null;
|
|
273
|
+
await record.settled;
|
|
274
|
+
return record.job;
|
|
275
|
+
},
|
|
276
|
+
async start(id, begin) {
|
|
277
|
+
const job: Job = {
|
|
278
|
+
id: id ?? randomUUID(),
|
|
279
|
+
status: "running",
|
|
280
|
+
agentId: null,
|
|
281
|
+
error: null,
|
|
282
|
+
startedAt: new Date().toISOString(),
|
|
283
|
+
};
|
|
284
|
+
let settle = () => {};
|
|
285
|
+
const settled = new Promise<void>((resolve) => {
|
|
286
|
+
settle = resolve;
|
|
287
|
+
});
|
|
288
|
+
const record: JobRecord<T> = { job, value: null, settled };
|
|
289
|
+
jobs.set(job.id, record);
|
|
290
|
+
const fail = (error: unknown) => {
|
|
291
|
+
record.job = {
|
|
292
|
+
...record.job,
|
|
293
|
+
status: "failed",
|
|
294
|
+
error: error instanceof Error ? error.message : String(error),
|
|
295
|
+
};
|
|
296
|
+
settle();
|
|
297
|
+
};
|
|
298
|
+
try {
|
|
299
|
+
const started = await begin();
|
|
300
|
+
record.job = { ...record.job, agentId: started.agentId };
|
|
301
|
+
started.result.then((value) => {
|
|
302
|
+
record.value = value;
|
|
303
|
+
record.job = { ...record.job, status: "done" };
|
|
304
|
+
settle();
|
|
305
|
+
}, fail);
|
|
306
|
+
} catch (error) {
|
|
307
|
+
fail(error);
|
|
308
|
+
}
|
|
309
|
+
return record.job;
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
}
|