@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,126 @@
1
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { type ZodType, z } from "zod";
4
+ import { type IssueMap, IssueMapSchema, type LaunchInput, LaunchInputSchema } from "../shared/live";
5
+
6
+ const MAX_MAPS = 200;
7
+ /** A launch still waiting after this long is dropped, so a stale one never starts by surprise. */
8
+ export const PENDING_LAUNCH_MAX_AGE_MS = 60 * 60_000;
9
+
10
+ interface JsonFile<T> {
11
+ read(): Promise<T>;
12
+ /** Changes run one at a time, so a read, change, and write never interleave. */
13
+ update<R>(change: (current: T) => { next: T; result: R }): Promise<R>;
14
+ }
15
+
16
+ export function createJsonFile<T>(file: string, schema: ZodType<T>, empty: () => T): JsonFile<T> {
17
+ let value: T | null = null;
18
+ let queue: Promise<unknown> = Promise.resolve();
19
+ async function load(): Promise<T> {
20
+ if (value !== null) return value;
21
+ try {
22
+ value = schema.parse(JSON.parse(await readFile(file, "utf8")));
23
+ } catch {
24
+ value = empty();
25
+ }
26
+ return value;
27
+ }
28
+ async function save(next: T) {
29
+ value = next;
30
+ await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
31
+ const temporary = `${file}.${process.pid}.tmp`;
32
+ await writeFile(temporary, JSON.stringify(next), { mode: 0o600 });
33
+ await rename(temporary, file);
34
+ }
35
+ return {
36
+ read: load,
37
+ update(change) {
38
+ const run = queue
39
+ .catch(() => undefined)
40
+ .then(async () => {
41
+ const { next, result } = change(await load());
42
+ await save(next);
43
+ return result;
44
+ });
45
+ queue = run;
46
+ return run;
47
+ },
48
+ };
49
+ }
50
+
51
+ export interface MapStore {
52
+ get(identifier: string): Promise<IssueMap | null>;
53
+ set(map: IssueMap): Promise<void>;
54
+ delete(identifier: string): Promise<void>;
55
+ }
56
+
57
+ /** Explore maps survive daemon restarts, so a finished explore run is never paid for twice. */
58
+ export function createMapStore(file: string): MapStore {
59
+ const store = createJsonFile<Record<string, IssueMap>>(
60
+ file,
61
+ z.record(z.string(), IssueMapSchema),
62
+ () => ({}),
63
+ );
64
+ return {
65
+ async get(identifier) {
66
+ return (await store.read())[identifier] ?? null;
67
+ },
68
+ set(map) {
69
+ return store.update((current) => {
70
+ const kept = Object.values({ ...current, [map.identifier]: map })
71
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
72
+ .slice(0, MAX_MAPS);
73
+ return { next: Object.fromEntries(kept.map((entry) => [entry.identifier, entry])), result: undefined };
74
+ });
75
+ },
76
+ delete(identifier) {
77
+ return store.update((current) => {
78
+ const { [identifier]: _removed, ...rest } = current;
79
+ return { next: rest, result: undefined };
80
+ });
81
+ },
82
+ };
83
+ }
84
+
85
+ const PendingLaunchSchema = LaunchInputSchema.extend({ queuedAt: z.number() });
86
+ type PendingLaunch = z.infer<typeof PendingLaunchSchema>;
87
+
88
+ export interface LaunchQueue {
89
+ add(launch: LaunchInput): Promise<void>;
90
+ /** Removes and returns the issue's waiting launches, so each one starts at most once. */
91
+ claim(identifier: string): Promise<LaunchInput[]>;
92
+ }
93
+
94
+ // Agents that wait for an explore run are saved to disk, so a plugin reload or daemon
95
+ // restart while the run works still starts them once the map is ready.
96
+ export function createLaunchQueue(file: string, now = () => Date.now()): LaunchQueue {
97
+ const store = createJsonFile<Record<string, PendingLaunch[]>>(
98
+ file,
99
+ z.record(z.string(), z.array(PendingLaunchSchema)),
100
+ () => ({}),
101
+ );
102
+ return {
103
+ add(launch) {
104
+ return store.update((current) => ({
105
+ next: {
106
+ ...current,
107
+ [launch.identifier]: [...(current[launch.identifier] ?? []), { ...launch, queuedAt: now() }],
108
+ },
109
+ result: undefined,
110
+ }));
111
+ },
112
+ claim(identifier) {
113
+ return store.update((current) => {
114
+ const { [identifier]: waiting = [], ...others } = current;
115
+ const fresh = waiting.filter((launch) => now() - launch.queuedAt < PENDING_LAUNCH_MAX_AGE_MS);
116
+ if (fresh.length < waiting.length) {
117
+ console.error(`Dropped ${waiting.length - fresh.length} stale Linear launch for ${identifier}`);
118
+ }
119
+ return {
120
+ next: others,
121
+ result: fresh.map(({ queuedAt: _queuedAt, ...launch }: PendingLaunch) => launch),
122
+ };
123
+ });
124
+ },
125
+ };
126
+ }
@@ -0,0 +1,146 @@
1
+ import { open, readdir, readFile, stat } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+ import type { SubagentLog } from "../shared/live";
5
+
6
+ // Claude writes each subagent's transcript beside the session, also for subagents that run in
7
+ // the background and send nothing to the parent timeline. This reads their tool calls and turns
8
+ // them into timeline items the panel already understands. Nothing here writes or runs anything.
9
+
10
+ const SESSION_ID = /^[A-Za-z0-9-]{8,80}$/;
11
+ const MAX_RUNS = 8;
12
+ const MAX_TAIL_BYTES = 512 * 1024;
13
+ const MAX_ITEMS = 120;
14
+
15
+ /** Folders that can hold Claude's `projects` folder: the env setting, the default, Paseo homes. */
16
+ async function claudeHomes(): Promise<string[]> {
17
+ const homes = [process.env.CLAUDE_CONFIG_DIR, path.join(homedir(), ".claude")];
18
+ const paseoHome = process.env.PASEO_HOME || path.join(homedir(), ".paseo");
19
+ const accounts = await readdir(path.join(paseoHome, "zerosub", "homes")).catch(() => []);
20
+ for (const name of accounts) homes.push(path.join(paseoHome, "zerosub", "homes", name));
21
+ return [...new Set(homes.filter((home): home is string => Boolean(home)))];
22
+ }
23
+
24
+ const found = new Map<string, string>();
25
+
26
+ async function isDirectory(target: string): Promise<boolean> {
27
+ return (await stat(target).catch(() => null))?.isDirectory() ?? false;
28
+ }
29
+
30
+ /** The session's `subagents` folder. Claude names the project folder after the cwd. */
31
+ export async function subagentFolder(sessionId: string, cwd: string): Promise<string | null> {
32
+ if (!SESSION_ID.test(sessionId)) return null;
33
+ const known = found.get(sessionId);
34
+ if (known) return known;
35
+ const slug = cwd.replace(/[^A-Za-z0-9]/g, "-");
36
+ for (const home of await claudeHomes()) {
37
+ const projects = path.join(home, "projects");
38
+ const guess = path.join(projects, slug, sessionId, "subagents");
39
+ const names = (await isDirectory(guess)) ? [slug] : await readdir(projects).catch(() => []);
40
+ for (const name of names) {
41
+ const folder = path.join(projects, name, sessionId, "subagents");
42
+ if (await isDirectory(folder)) {
43
+ found.set(sessionId, folder);
44
+ return folder;
45
+ }
46
+ }
47
+ }
48
+ return null;
49
+ }
50
+
51
+ async function tail(file: string): Promise<string> {
52
+ const handle = await open(file, "r");
53
+ try {
54
+ const { size } = await handle.stat();
55
+ const start = Math.max(0, size - MAX_TAIL_BYTES);
56
+ const buffer = Buffer.alloc(size - start);
57
+ await handle.read(buffer, 0, buffer.length, start);
58
+ const text = buffer.toString("utf8");
59
+ // A cut first line is not valid JSON; drop it.
60
+ return start > 0 ? text.slice(text.indexOf("\n") + 1) : text;
61
+ } finally {
62
+ await handle.close();
63
+ }
64
+ }
65
+
66
+ type Block = Record<string, unknown>;
67
+ type Item = SubagentLog["items"][number];
68
+
69
+ const str = (value: unknown) => (typeof value === "string" ? value : "");
70
+ const lines = (value: unknown) => (str(value) ? str(value).replace(/\n$/, "").split("\n").length : 0);
71
+
72
+ /** A Claude tool call as a Paseo-style tool call item, with paths moved from the worktree. */
73
+ export function toolItem(block: Block, move: (value: string) => string, cwd: string): Item {
74
+ const input = (block.input ?? {}) as Block;
75
+ const name = str(block.name);
76
+ const file = move(str(input.file_path) || str(input.notebook_path));
77
+ let detail: Record<string, unknown> = { type: "unknown" };
78
+ if (name === "Read" && file) detail = { type: "read", filePath: file };
79
+ else if ((name === "Edit" || name === "MultiEdit" || name === "NotebookEdit") && file) {
80
+ const edits = Array.isArray(input.edits) ? (input.edits as Block[]) : [input];
81
+ const added = edits.reduce((sum, edit) => sum + lines(edit.new_string ?? edit.new_source), 0);
82
+ const removed = edits.reduce((sum, edit) => sum + lines(edit.old_string), 0);
83
+ detail = { type: "edit", filePath: file, added, removed };
84
+ } else if (name === "Write" && file) detail = { type: "write", filePath: file, added: lines(input.content) };
85
+ else if (name === "Grep" || name === "Glob") detail = { type: "search", query: str(input.pattern) };
86
+ else if (name === "Bash") detail = { type: "shell", command: move(str(input.command)), cwd };
87
+ return { type: "tool_call", callId: str(block.id), name, status: "running", detail };
88
+ }
89
+
90
+ /** Tool calls in a transcript, newest last, with each finished call marked done or failed. */
91
+ export function transcriptItems(text: string, move: (value: string) => string, cwd: string) {
92
+ const items = new Map<string, Item>();
93
+ let finished = false;
94
+ for (const line of text.split("\n")) {
95
+ if (!line.trim()) continue;
96
+ let entry: Block;
97
+ try {
98
+ entry = JSON.parse(line) as Block;
99
+ } catch {
100
+ continue;
101
+ }
102
+ const message = (entry.message ?? {}) as Block;
103
+ const content = Array.isArray(message.content) ? (message.content as Block[]) : [];
104
+ if (entry.type === "assistant") {
105
+ finished = message.stop_reason === "end_turn" && !content.some((block) => block.type === "tool_use");
106
+ }
107
+ for (const block of content) {
108
+ if (block.type === "tool_use" && str(block.id)) items.set(str(block.id), toolItem(block, move, cwd));
109
+ const done = block.type === "tool_result" ? items.get(str(block.tool_use_id)) : undefined;
110
+ if (done) done.status = block.is_error ? "failed" : "completed";
111
+ }
112
+ }
113
+ return { items: [...items.values()].slice(-MAX_ITEMS), finished };
114
+ }
115
+
116
+ /** The newest subagents of a Claude session, with their tool calls. */
117
+ export async function subagentLogs(sessionId: string, cwd: string): Promise<SubagentLog[]> {
118
+ const folder = await subagentFolder(sessionId, cwd);
119
+ if (!folder) return [];
120
+ const names = (await readdir(folder).catch(() => [])).filter((name) => /^agent-[A-Za-z0-9]+\.jsonl$/.test(name));
121
+ const dated = await Promise.all(
122
+ names.map(async (name) => ({ name, at: (await stat(path.join(folder, name)).catch(() => null))?.mtimeMs ?? 0 })),
123
+ );
124
+ const newest = dated.sort((a, b) => b.at - a.at).slice(0, MAX_RUNS);
125
+ const runs: SubagentLog[] = [];
126
+ for (const { name, at } of newest) {
127
+ const id = name.slice("agent-".length, -".jsonl".length);
128
+ const meta = JSON.parse(
129
+ (await readFile(path.join(folder, `agent-${id}.meta.json`), "utf8").catch(() => "{}")) || "{}",
130
+ ) as Block;
131
+ const worktree = str(meta.worktreePath).replace(/\/+$/, "");
132
+ // A subagent in its own worktree sees the same repository, so its paths map onto the agent's.
133
+ const move = (value: string) => (worktree ? value.split(worktree).join(cwd) : value);
134
+ const { items, finished } = transcriptItems(await tail(path.join(folder, name)).catch(() => ""), move, cwd);
135
+ runs.push({
136
+ id,
137
+ type: str(meta.agentType) || "Subagent",
138
+ description: str(meta.description),
139
+ toolUseId: str(meta.toolUseId) || null,
140
+ updatedAt: Math.round(at),
141
+ finished,
142
+ items,
143
+ });
144
+ }
145
+ return runs;
146
+ }
package/server/sync.ts ADDED
@@ -0,0 +1,127 @@
1
+ import type { PluginServerContext } from "@getpaseo/plugin/server";
2
+ import type { WorkflowState } from "../shared/linear";
3
+ import { syncNowRpc } from "../shared/live";
4
+ import { AGENT_LABELS, isInternalAgent } from "../shared/prompts";
5
+ import { type LinearSettings, projectEnabled } from "../shared/settings";
6
+ import { planStatusMoves, progressByKey, type TodoLike } from "../shared/todo-sync";
7
+ import type { Paseo } from "./agent-runs";
8
+ import type { LinearAccess } from "./handlers";
9
+ import { agentInfo } from "./live";
10
+
11
+ const STATES_MAX_AGE_MS = 10 * 60_000;
12
+
13
+ export interface SyncDependencies {
14
+ access: LinearAccess;
15
+ readSettings(): Promise<LinearSettings | null>;
16
+ }
17
+
18
+ export interface SyncResult {
19
+ moves: { identifier: string; from: WorkflowState; to: WorkflowState }[];
20
+ skipped: string | null;
21
+ }
22
+
23
+ function latestTodos(items: readonly { type: string; items?: unknown }[]): TodoLike[] | null {
24
+ for (let index = items.length - 1; index >= 0; index -= 1) {
25
+ const item = items[index];
26
+ if (item?.type === "todo" && Array.isArray(item.items)) return item.items as TodoLike[];
27
+ }
28
+ return null;
29
+ }
30
+
31
+ export function registerSync(server: PluginServerContext, dependencies: SyncDependencies) {
32
+ const { access, readSettings } = dependencies;
33
+ const teamStates = new Map<string, { at: number; states: Map<string, WorkflowState[]> }>();
34
+ const queues = new Map<string, Promise<unknown>>();
35
+
36
+ async function statesFor(projectId: string | null, teamId: string) {
37
+ const { linear, fingerprint } = await access.connect(projectId);
38
+ const cached = teamStates.get(fingerprint);
39
+ if (cached && Date.now() - cached.at < STATES_MAX_AGE_MS && cached.states.has(teamId)) {
40
+ return cached.states.get(teamId) ?? [];
41
+ }
42
+ const catalog = await linear.catalog();
43
+ const states = new Map(catalog.teams.map((team) => [team.id, team.states]));
44
+ teamStates.set(fingerprint, { at: Date.now(), states });
45
+ return states.get(teamId) ?? [];
46
+ }
47
+
48
+ // Reads Linear fresh, not the response cache, so a manual move since the last turn counts.
49
+ async function syncIssue(
50
+ identifier: string,
51
+ projectId: string | null,
52
+ todos: readonly TodoLike[],
53
+ ): Promise<SyncResult> {
54
+ const progress = progressByKey(todos);
55
+ if (progress.size === 0) return { moves: [], skipped: "No todo starts with an issue key." };
56
+ const { linear } = await access.connect(projectId);
57
+ const issue = await linear.getIssue(identifier);
58
+ const states = await statesFor(projectId, issue.team.id);
59
+ const moves = planStatusMoves({ parent: issue, progress, states });
60
+ const applied: SyncResult["moves"] = [];
61
+ for (const move of moves) {
62
+ await access.mutate(projectId, (service) =>
63
+ service.updateIssue(move.issueId, { stateId: move.to.id }),
64
+ );
65
+ applied.push({ identifier: move.identifier, from: move.from, to: move.to });
66
+ }
67
+ if (applied.length > 0) {
68
+ console.log(`Linear sync moved ${applied.map((m) => `${m.identifier} to ${m.to.name}`).join(", ")}`);
69
+ }
70
+ return { moves: applied, skipped: null };
71
+ }
72
+
73
+ // One sync per issue at a time, so two turns ending together cannot move an issue twice.
74
+ function queued(identifier: string, run: () => Promise<SyncResult>): Promise<SyncResult> {
75
+ const previous = queues.get(identifier) ?? Promise.resolve();
76
+ const next = previous.catch(() => undefined).then(run);
77
+ queues.set(identifier, next);
78
+ void next.finally(() => {
79
+ if (queues.get(identifier) === next) queues.delete(identifier);
80
+ });
81
+ return next;
82
+ }
83
+
84
+ async function syncAgent(
85
+ paseo: Paseo,
86
+ agentId: string,
87
+ todos: readonly TodoLike[] | null,
88
+ ): Promise<SyncResult> {
89
+ const agent = await agentInfo(paseo, agentId);
90
+ const settings = await readSettings();
91
+ if (settings && !projectEnabled(settings.access, agent.projectId)) {
92
+ return { moves: [], skipped: "Linear is off for this project." };
93
+ }
94
+ const identifier = agent.labels[AGENT_LABELS.issue];
95
+ if (!identifier || isInternalAgent(agent.labels)) {
96
+ return { moves: [], skipped: "This agent is not linked to a Linear issue." };
97
+ }
98
+ if (agent.labels[AGENT_LABELS.action] !== "implement") {
99
+ return { moves: [], skipped: "Only implementing agents update Linear statuses." };
100
+ }
101
+ let items = todos;
102
+ if (!items) {
103
+ const page = await paseo.agents
104
+ .ref(agentId)
105
+ .timeline.refetch({ direction: "tail", limit: 400, projection: "projected" });
106
+ items = latestTodos(page.entries.map((entry) => entry.item));
107
+ }
108
+ if (!items) return { moves: [], skipped: "The agent has no todos yet." };
109
+ const projectId = agent.labels[AGENT_LABELS.project] ?? null;
110
+ const todoItems = items;
111
+ return queued(identifier, () => syncIssue(identifier, projectId, todoItems));
112
+ }
113
+
114
+ server.on("agent.turn_ended", async (event, { paseo }) => {
115
+ const settings = await readSettings();
116
+ if (!settings?.live.syncTodos) return;
117
+ const todos = latestTodos(event.timeline);
118
+ if (!todos) return;
119
+ try {
120
+ await syncAgent(paseo, event.agent.id, todos);
121
+ } catch (error) {
122
+ console.error("Linear sync failed", error);
123
+ }
124
+ });
125
+
126
+ server.handle(syncNowRpc, async ({ agentId }, { paseo }) => syncAgent(paseo, agentId, null));
127
+ }