@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.
Files changed (63) hide show
  1. package/OVERVIEW.md +32 -2
  2. package/README.md +134 -8
  3. package/client/access.tsx +67 -0
  4. package/client/browser.tsx +12 -9
  5. package/client/compat.ts +23 -5
  6. package/client/connect.tsx +3 -2
  7. package/client/launch-assignments.tsx +182 -0
  8. package/client/launch-choices.ts +21 -6
  9. package/client/launch-guidance.tsx +103 -0
  10. package/client/launch-plan.ts +114 -17
  11. package/client/launch.tsx +55 -9
  12. package/client/live-agents.tsx +446 -0
  13. package/client/live-feed.tsx +151 -0
  14. package/client/live-header.tsx +310 -0
  15. package/client/live-issues.tsx +345 -0
  16. package/client/live-map.tsx +351 -0
  17. package/client/live-panel.tsx +392 -0
  18. package/client/live-timeline.ts +379 -0
  19. package/client/pill.tsx +394 -0
  20. package/client/plugin-icon.tsx +75 -0
  21. package/client/project-init.tsx +192 -0
  22. package/client/project-knowledge.tsx +87 -0
  23. package/client/settings-access.tsx +56 -0
  24. package/client/settings-agents.tsx +113 -0
  25. package/client/settings-fields.tsx +11 -3
  26. package/client/settings-icon.tsx +277 -0
  27. package/client/settings-live.tsx +128 -0
  28. package/client/settings-overrides.tsx +148 -0
  29. package/client/settings-projects.tsx +26 -4
  30. package/client/settings-prompts.tsx +3 -3
  31. package/client/timeline-card.tsx +2 -1
  32. package/client/tool-buttons.tsx +68 -0
  33. package/client/ui.tsx +16 -7
  34. package/client/web.ts +81 -0
  35. package/index.client.tsx +160 -51
  36. package/index.server.ts +32 -5
  37. package/package.json +1 -1
  38. package/server/agent-hooks.ts +37 -0
  39. package/server/agent-runs.ts +312 -0
  40. package/server/agent-tools.ts +276 -0
  41. package/server/handlers.ts +12 -1
  42. package/server/init.ts +89 -0
  43. package/server/inspect.ts +392 -0
  44. package/server/knowledge.ts +157 -0
  45. package/server/live.ts +371 -0
  46. package/server/mcp-http.ts +156 -0
  47. package/server/stores.ts +126 -0
  48. package/server/subagent-logs.ts +146 -0
  49. package/server/sync.ts +133 -0
  50. package/shared/activity.ts +268 -0
  51. package/shared/agent-hooks.ts +68 -0
  52. package/shared/agent-tools.ts +157 -0
  53. package/shared/custom-icon.ts +188 -0
  54. package/shared/knowledge.ts +255 -0
  55. package/shared/live.ts +248 -0
  56. package/shared/map-model.ts +240 -0
  57. package/shared/project-setup.ts +56 -0
  58. package/shared/prompts.ts +109 -2
  59. package/shared/settings.ts +145 -3
  60. package/shared/shell-activity.ts +309 -0
  61. package/shared/shell-lexer.ts +180 -0
  62. package/shared/subagents.ts +106 -0
  63. package/shared/todo-sync.ts +158 -0
@@ -0,0 +1,379 @@
1
+ import { usePaseo, useRpc } from "@getpaseo/plugin/client";
2
+ import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import { useCallback, useEffect, useMemo, useState } from "react";
4
+ import { AccessibilityInfo, Platform } from "react-native";
5
+ import type { DirTouch, FileTouch, TimelineItemLike } from "../shared/activity";
6
+ import { type AreaRef, liveProjectRpc, MAX_RESOLVE_PATHS } from "../shared/knowledge";
7
+ import type { IssueDetail } from "../shared/linear";
8
+ import {
9
+ clearMapRpc,
10
+ exploreRpc,
11
+ type IssueMap,
12
+ type Job,
13
+ listFilesRpc,
14
+ liveMapRpc,
15
+ type SubagentLog,
16
+ subagentLogsRpc,
17
+ MAX_LIST_DIRS,
18
+ type MapFile,
19
+ semanticMap,
20
+ } from "../shared/live";
21
+ import { buildMapModel, type MapModel, zoneDirs } from "../shared/map-model";
22
+ import type { MappingMode } from "../shared/settings";
23
+ import { errorMessage } from "./ui";
24
+
25
+ export interface AgentTimeline {
26
+ items: TimelineItemLike[];
27
+ loading: boolean;
28
+ error: string | null;
29
+ }
30
+
31
+ const TAIL_LIMIT = 400;
32
+ const MAX_ITEMS = 600;
33
+ const FLUSH_MS = 150;
34
+ const TODO_SLOT = "todo";
35
+ // Assistant text and reasoning stream per token and carry no file activity.
36
+ const SKIPPED = new Set(["assistant_message", "reasoning"]);
37
+ const INITIAL: AgentTimeline = { items: [], loading: true, error: null };
38
+
39
+ function asItem(value: unknown): TimelineItemLike | null {
40
+ if (!value || typeof value !== "object") return null;
41
+ return typeof (value as { type?: unknown }).type === "string"
42
+ ? (value as TimelineItemLike)
43
+ : null;
44
+ }
45
+
46
+ // Ordered slots: a tool call keeps its first position as later copies replace it, the single
47
+ // todo slot moves to the end on each update, and the cap drops the oldest other slot.
48
+ function createSlots() {
49
+ const slots = new Map<string, TimelineItemLike>();
50
+ let counter = 0;
51
+ return {
52
+ add(item: TimelineItemLike): boolean {
53
+ if (SKIPPED.has(item.type)) return false;
54
+ let key = `item:${counter++}`;
55
+ if (item.type === "todo") {
56
+ key = TODO_SLOT;
57
+ slots.delete(TODO_SLOT);
58
+ } else if (item.type === "tool_call" && typeof item.callId === "string") {
59
+ key = `call:${item.callId}`;
60
+ }
61
+ slots.set(key, item);
62
+ for (const oldest of slots.keys()) {
63
+ if (slots.size <= MAX_ITEMS) break;
64
+ if (oldest !== TODO_SLOT) slots.delete(oldest);
65
+ }
66
+ return true;
67
+ },
68
+ clear() {
69
+ slots.clear();
70
+ },
71
+ values(): TimelineItemLike[] {
72
+ return [...slots.values()];
73
+ },
74
+ };
75
+ }
76
+
77
+ function later(run: () => void, ms: number): () => void {
78
+ const id = setTimeout(run, ms);
79
+ return () => clearTimeout(id);
80
+ }
81
+
82
+ type Paseo = ReturnType<typeof usePaseo>;
83
+ type Update = AgentTimeline | ((current: AgentTimeline) => AgentTimeline);
84
+
85
+ /** Watches one agent's tool calls and todos, batched about every 150 ms. Returns cleanup. */
86
+ function watchTimeline(paseo: Paseo, agentId: string, setState: (update: Update) => void) {
87
+ const handle = paseo.agents.ref(agentId);
88
+ const slots = createSlots();
89
+ let disposed = false;
90
+ let request = 0;
91
+ let replay: TimelineItemLike[] | null = [];
92
+ let cancelFlush: (() => void) | null = null;
93
+ let streamError: string | null = null;
94
+ setState(INITIAL);
95
+
96
+ const flush = () => {
97
+ cancelFlush?.();
98
+ cancelFlush = null;
99
+ if (!disposed) setState({ items: slots.values(), loading: false, error: streamError });
100
+ };
101
+ const schedule = () => {
102
+ if (!cancelFlush) cancelFlush = later(flush, FLUSH_MS);
103
+ };
104
+ const fail = (message: string) => {
105
+ if (disposed) return;
106
+ setState((current) => ({ ...current, loading: false, error: message }));
107
+ };
108
+ const streamFailed = (message: string) => {
109
+ streamError = message;
110
+ fail(message);
111
+ };
112
+
113
+ // Events that arrive while the tail loads are applied again on top of it.
114
+ const load = async () => {
115
+ const mine = ++request;
116
+ if (!replay) replay = [];
117
+ try {
118
+ const page = await handle.timeline.refetch({
119
+ direction: "tail",
120
+ limit: TAIL_LIMIT,
121
+ projection: "projected",
122
+ });
123
+ if (disposed || mine !== request) return;
124
+ const pending = replay ?? [];
125
+ replay = null;
126
+ slots.clear();
127
+ for (const entry of page.entries) {
128
+ const item = asItem(entry.item);
129
+ if (item) slots.add(item);
130
+ }
131
+ for (const item of pending) slots.add(item);
132
+ flush();
133
+ } catch (error) {
134
+ if (mine !== request) return;
135
+ replay = null;
136
+ fail(errorMessage(error));
137
+ }
138
+ };
139
+
140
+ const subscription = handle.timeline.subscribe((event) => {
141
+ if (disposed) return;
142
+ const inner = event.event;
143
+ if (inner.type === "timeline") {
144
+ const item = asItem(inner.item);
145
+ if (!item || !slots.add(item)) return;
146
+ replay?.push(item);
147
+ schedule();
148
+ } else if (inner.type === "replacement" || inner.type === "subscription_restored") {
149
+ streamError = null;
150
+ replay = null;
151
+ void load();
152
+ } else if (inner.type === "error") {
153
+ streamFailed(inner.error);
154
+ }
155
+ });
156
+
157
+ void subscription.ready
158
+ .catch((error: unknown) => streamFailed(errorMessage(error)))
159
+ .then(() => {
160
+ if (!disposed && request === 0) void load();
161
+ });
162
+
163
+ return () => {
164
+ disposed = true;
165
+ cancelFlush?.();
166
+ subscription();
167
+ };
168
+ }
169
+
170
+ /** Tool calls and todos of one agent, live. */
171
+ export function useAgentTimeline(agentId: string): AgentTimeline {
172
+ const paseo = usePaseo();
173
+ const [state, setState] = useState(INITIAL);
174
+ useEffect(() => watchTimeline(paseo, agentId, setState), [paseo, agentId]);
175
+ return state;
176
+ }
177
+
178
+ const NO_TIMELINES: ReadonlyMap<string, AgentTimeline> = new Map();
179
+
180
+ /** Timelines of several agents, such as a parent's child agents, keyed by agent id. */
181
+ export function useAgentTimelines(agentIds: readonly string[]): ReadonlyMap<string, AgentTimeline> {
182
+ const paseo = usePaseo();
183
+ const [state, setState] = useState(NO_TIMELINES);
184
+ const key = agentIds.join(",");
185
+ useEffect(() => {
186
+ const ids = key ? key.split(",") : [];
187
+ // Keep the timelines of agents still in the list until their new watch loads, so they do not blink.
188
+ setState((current) => new Map([...current].filter(([id]) => ids.includes(id))));
189
+ const stops = ids.map((id) =>
190
+ watchTimeline(paseo, id, (update) =>
191
+ setState((current) => {
192
+ const known = current.get(id);
193
+ if (update === INITIAL && known) return current;
194
+ const next = new Map(current);
195
+ next.set(id, typeof update === "function" ? update(known ?? INITIAL) : update);
196
+ return next;
197
+ }),
198
+ ),
199
+ );
200
+ return () => {
201
+ for (const stop of stops) stop();
202
+ };
203
+ }, [paseo, key]);
204
+ return state;
205
+ }
206
+
207
+ // Shared by the map and the panel: motion, sub-issue colors, map data, and the map model.
208
+ export const NATIVE_DRIVER = Platform.OS !== "web";
209
+ const PALETTE = ["#5e6ad2", "#26b5ce", "#f2994a", "#bb87fc", "#4cb782", "#eb5757", "#e2b93d"];
210
+
211
+ /** True until the platform answers, so nothing animates for someone who asked for less. */
212
+ export function useReduceMotion(): boolean {
213
+ const [reduced, setReduced] = useState(true);
214
+ useEffect(() => {
215
+ let active = true;
216
+ AccessibilityInfo.isReduceMotionEnabled()
217
+ .then((value) => active && setReduced(value))
218
+ .catch(() => active && setReduced(false));
219
+ return () => {
220
+ active = false;
221
+ };
222
+ }, []);
223
+ return reduced;
224
+ }
225
+
226
+ export type OwnerColors = ReadonlyMap<string, string>;
227
+
228
+ /** The parent gets the accent; each sub-issue gets a palette color by its position. */
229
+ export function ownerColors(issue: IssueDetail, accent: string): OwnerColors {
230
+ const colors = new Map([[issue.identifier.toUpperCase(), accent]]);
231
+ issue.children.forEach((child, index) => {
232
+ colors.set(child.identifier.toUpperCase(), PALETTE[index % PALETTE.length] ?? accent);
233
+ });
234
+ return colors;
235
+ }
236
+
237
+ // Map data: the stored map, else one from the ticket text. Polls fast while an explore job
238
+ // runs, and slowly while there is no map, since a launch can start a job after this mounts.
239
+ export interface LiveMap {
240
+ map: IssueMap;
241
+ /** Services, packages, and main folders of the agent's project, from its saved knowledge. */
242
+ areas: readonly AreaRef[];
243
+ stored: boolean;
244
+ /** The stored map has been read once, so the map no longer swaps from the fallback. */
245
+ ready: boolean;
246
+ job: Job | null;
247
+ starting: boolean;
248
+ error: string | null;
249
+ explore(force: boolean): void;
250
+ /** Reads the map, folder listings, and subagents again. */
251
+ refresh(): void;
252
+ /** Forgets the saved explore map; the map falls back to the ticket text. */
253
+ clear(): void;
254
+ clearing: boolean;
255
+ }
256
+
257
+ export function useLiveMap(
258
+ agentId: string,
259
+ identifier: string,
260
+ issue: IssueDetail,
261
+ mapping: MappingMode,
262
+ ): LiveMap {
263
+ const read = useRpc(liveMapRpc);
264
+ const start = useRpc(exploreRpc);
265
+ const query = useQuery({
266
+ queryKey: ["linear", "live", "map", identifier],
267
+ queryFn: () => read({ identifier }),
268
+ refetchInterval: (current) => {
269
+ const data = current.state.data;
270
+ if (data?.job?.status === "running") return 4_000;
271
+ return data?.map || mapping !== "explore" ? false : 15_000;
272
+ },
273
+ staleTime: 10_000,
274
+ });
275
+ const { refetch } = query;
276
+ const explore = useMutation({
277
+ mutationFn: (force: boolean) => start({ agentId, force }),
278
+ onSettled: () => void refetch(),
279
+ });
280
+ const { mutate } = explore;
281
+ const queries = useQueryClient();
282
+ const refresh = useCallback(() => void queries.invalidateQueries({ queryKey: ["linear", "live"] }), [queries]);
283
+ const clearMap = useRpc(clearMapRpc);
284
+ const clear = useMutation({ mutationFn: () => clearMap({ agentId }), onSettled: refresh });
285
+ const project = useProjectKnowledge(agentId, issue);
286
+ const stored = query.data?.map ?? null;
287
+ const failure = explore.error ?? clear.error ?? query.error;
288
+ return {
289
+ map: stored ?? project.fallback,
290
+ areas: project.areas,
291
+ stored: stored !== null,
292
+ ready: (query.data !== undefined || query.isError) && project.ready,
293
+ job: query.data?.job ?? null,
294
+ starting: explore.isPending,
295
+ error: failure ? errorMessage(failure) : null,
296
+ explore: mutate,
297
+ refresh,
298
+ clear: clear.mutate as () => void,
299
+ clearing: clear.isPending,
300
+ };
301
+ }
302
+
303
+ const NO_AREAS: readonly AreaRef[] = [];
304
+
305
+ /** The project's areas, and the ticket-text map with its paths matched to real files. */
306
+ function useProjectKnowledge(agentId: string, issue: IssueDetail) {
307
+ const read = useRpc(liveProjectRpc);
308
+ const base = useMemo(() => semanticMap(issue), [issue]);
309
+ const paths = useMemo(
310
+ () => base.files.map((file) => file.path).slice(0, MAX_RESOLVE_PATHS).sort(),
311
+ [base],
312
+ );
313
+ const query = useQuery({
314
+ queryKey: ["linear", "live", "project", agentId, paths],
315
+ queryFn: () => read({ agentId, paths }),
316
+ staleTime: 5 * 60_000,
317
+ // Without knowledge the map still works, so a failure shows it at once.
318
+ retry: false,
319
+ });
320
+ const { data } = query;
321
+ const fallback = useMemo(() => {
322
+ if (!data) return base;
323
+ const resolved = new Map(data.resolved.map((entry) => [entry.from, entry.to]));
324
+ return semanticMap(issue, new Date(base.createdAt), { resolved, areas: data.areas });
325
+ }, [issue, base, data]);
326
+ return { fallback, areas: data?.areas ?? NO_AREAS, ready: data !== undefined || query.isError };
327
+ }
328
+
329
+ const NO_LISTING: ReadonlyMap<string, readonly string[]> = new Map();
330
+
331
+ export function useMapModel(
332
+ agentId: string,
333
+ files: readonly MapFile[],
334
+ touches: readonly FileTouch[],
335
+ explored: readonly DirTouch[],
336
+ areas: readonly AreaRef[] = NO_AREAS,
337
+ ): MapModel & { ready: boolean } {
338
+ const list = useRpc(listFilesRpc);
339
+ const dirs = useMemo(
340
+ () => zoneDirs(files, touches).slice(0, MAX_LIST_DIRS).sort(),
341
+ [files, touches],
342
+ );
343
+ const listing = useQuery({
344
+ queryKey: ["linear", "live", "files", agentId, dirs],
345
+ queryFn: () => list({ agentId, dirs }),
346
+ enabled: dirs.length > 0,
347
+ placeholderData: keepPreviousData,
348
+ staleTime: 30_000,
349
+ });
350
+ const byDir = useMemo(() => {
351
+ if (!listing.data) return NO_LISTING;
352
+ return new Map(listing.data.dirs.map((entry) => [entry.dir, entry.files]));
353
+ }, [listing.data]);
354
+ const ready = dirs.length === 0 || listing.data !== undefined || listing.isError;
355
+ return useMemo(
356
+ () => ({ ...buildMapModel(files, touches, byDir, explored, areas), ready }),
357
+ [files, touches, byDir, explored, areas, ready],
358
+ );
359
+ }
360
+
361
+ const NO_RUNS: readonly SubagentLog[] = [];
362
+
363
+ /** Tool calls of the agent's subagents, read again every few seconds while the agent works. */
364
+ export function useSubagentLogs(
365
+ agentId: string,
366
+ enabled: boolean,
367
+ polling: boolean,
368
+ ): { runs: readonly SubagentLog[]; checkedAt: number } {
369
+ const read = useRpc(subagentLogsRpc);
370
+ const query = useQuery({
371
+ queryKey: ["linear", "live", "subagents", agentId],
372
+ queryFn: () => read({ agentId }),
373
+ enabled,
374
+ refetchInterval: polling ? 3_000 : false,
375
+ placeholderData: keepPreviousData,
376
+ staleTime: 2_000,
377
+ });
378
+ return { runs: query.data?.runs ?? NO_RUNS, checkedAt: query.dataUpdatedAt };
379
+ }