@yannelli/paseo-linear-plugin 1.1.0-beta.0 → 1.1.0-beta.2

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 (44) hide show
  1. package/OVERVIEW.md +7 -3
  2. package/README.md +38 -11
  3. package/client/access.tsx +2 -1
  4. package/client/compat.ts +23 -5
  5. package/client/connect.tsx +3 -2
  6. package/client/graph-camera.tsx +219 -0
  7. package/client/graph-labels.ts +14 -4
  8. package/client/launch-guidance.tsx +86 -0
  9. package/client/launch-plan.ts +50 -2
  10. package/client/launch.tsx +10 -1
  11. package/client/live-agents.tsx +138 -23
  12. package/client/live-cursor.tsx +3 -1
  13. package/client/live-graph.tsx +159 -75
  14. package/client/live-header.tsx +29 -0
  15. package/client/live-panel.tsx +29 -8
  16. package/client/live-timeline.ts +38 -3
  17. package/client/pill.tsx +4 -3
  18. package/client/plugin-icon.tsx +43 -0
  19. package/client/settings-fields.tsx +11 -3
  20. package/client/settings-icon.tsx +228 -0
  21. package/client/settings-live.tsx +25 -1
  22. package/client/timeline-card.tsx +2 -1
  23. package/client/tool-buttons.tsx +68 -0
  24. package/client/ui.tsx +16 -7
  25. package/client/web.ts +91 -0
  26. package/index.client.tsx +9 -0
  27. package/index.server.ts +5 -1
  28. package/package.json +1 -1
  29. package/server/agent-hooks.ts +37 -0
  30. package/server/agent-runs.ts +15 -2
  31. package/server/agent-tools.ts +240 -0
  32. package/server/init.ts +1 -0
  33. package/server/live.ts +27 -1
  34. package/server/mcp-http.ts +152 -0
  35. package/server/stores.ts +8 -1
  36. package/server/subagent-logs.ts +146 -0
  37. package/shared/activity.ts +5 -1
  38. package/shared/agent-hooks.ts +68 -0
  39. package/shared/agent-tools.ts +130 -0
  40. package/shared/custom-icon.ts +148 -0
  41. package/shared/graph-camera.ts +93 -0
  42. package/shared/live.ts +43 -0
  43. package/shared/prompts.ts +49 -1
  44. package/shared/settings.ts +34 -0
@@ -1,3 +1,4 @@
1
+ import type { RpcOutput } from "@getpaseo/plugin";
1
2
  import { usePaseo, useRpc } from "@getpaseo/plugin/client";
2
3
  import { useMutation } from "@tanstack/react-query";
3
4
  import {
@@ -7,9 +8,11 @@ import {
7
8
  updateIssueRpc,
8
9
  type WorkflowState,
9
10
  } from "../shared/linear";
11
+ import { agentHooksRpc } from "../shared/agent-hooks";
12
+ import { agentToolsRpc, TOOLS_SERVER } from "../shared/agent-tools";
10
13
  import { launchAfterExploreRpc } from "../shared/live";
11
14
  import { AGENT_LABELS, agentTitle } from "../shared/prompts";
12
- import type { AgentAction, Isolation, LinearSettings } from "../shared/settings";
15
+ import type { AgentAction, Guidance, Isolation, LinearSettings } from "../shared/settings";
13
16
  import { type AgentSelection, agentConfig } from "./agent-options";
14
17
  import { rememberLaunch } from "./agent-preferences";
15
18
  import type { PaseoProjectOption } from "./queries";
@@ -144,6 +147,25 @@ export interface LaunchRequest {
144
147
  keyScope: string | null;
145
148
  /** Maps the files with the read-only explore agent first, then starts the agent. */
146
149
  explore: boolean;
150
+ /** Guidance options; for Claude some also add hooks. */
151
+ guidance: Guidance;
152
+ }
153
+
154
+ type AgentTools = RpcOutput<typeof agentToolsRpc>;
155
+
156
+ /** The config with the plugin's MCP server, whose token only reaches this issue tree. */
157
+ export function withLinearTools<C extends object>(config: C, tools: Pick<AgentTools, "server" | "toolPolicy"> | null) {
158
+ if (!tools) return config;
159
+ return { ...config, mcpServers: { [TOOLS_SERVER]: tools.server }, toolPolicy: tools.toolPolicy };
160
+ }
161
+
162
+ /** The issue's guidance needs the MCP tools: Linear edits, or agents started on sub-issues. */
163
+ export const needsLinearTools = (guidance: Guidance) => guidance.updateLinear || guidance.paseoSubagents;
164
+
165
+ /** The config with the issue's Claude Code hooks, loaded as a plugin with --plugin-dir. */
166
+ export function withHookPlugin<C extends object>(config: C, pluginDir: string | null) {
167
+ if (!pluginDir) return config;
168
+ return { ...config, providerOptions: { extraArgs: { "plugin-dir": pluginDir } } };
147
169
  }
148
170
 
149
171
  export type Paseo = ReturnType<typeof usePaseo>;
@@ -217,6 +239,8 @@ export function useLaunchAgent() {
217
239
  const updateIssue = useRpc(updateIssueRpc);
218
240
  const attachCard = useRpc(attachIssueCardRpc);
219
241
  const launchAfterExplore = useRpc(launchAfterExploreRpc);
242
+ const prepareHooks = useRpc(agentHooksRpc);
243
+ const prepareTools = useRpc(agentToolsRpc);
220
244
  return useMutation({
221
245
  mutationFn: async (request: LaunchRequest) => {
222
246
  const { issue, action } = request;
@@ -230,14 +254,38 @@ export function useLaunchAgent() {
230
254
  stateColor: state.color,
231
255
  action,
232
256
  };
257
+ // Hooks are a Claude Code feature; a failure to write them never blocks the launch.
258
+ const hooks =
259
+ request.agent.agent.id === "claude"
260
+ ? await prepareHooks({
261
+ issue: {
262
+ identifier: issue.identifier,
263
+ title: issue.title,
264
+ children: issue.children.map(({ identifier, title }) => ({ identifier, title })),
265
+ },
266
+ guidance: request.guidance,
267
+ }).catch(() => null)
268
+ : null;
269
+ const config = agentConfig(request.agent);
270
+ // Without the tools the agent still starts; its prompt then names tools it does not have.
271
+ const tools = needsLinearTools(request.guidance)
272
+ ? await prepareTools({
273
+ root: issue.identifier,
274
+ home: issue.identifier,
275
+ keyScope: request.keyScope,
276
+ workspaceId: workspace.id,
277
+ config,
278
+ }).catch(() => null)
279
+ : null;
233
280
  const agent = {
234
- config: agentConfig(request.agent),
281
+ config: withLinearTools(withHookPlugin(config, hooks?.pluginDir ?? null), tools),
235
282
  title: agentTitle(action, issue),
236
283
  prompt: request.prompt,
237
284
  labels: {
238
285
  [AGENT_LABELS.issue]: issue.identifier,
239
286
  [AGENT_LABELS.action]: action,
240
287
  ...(request.keyScope ? { [AGENT_LABELS.project]: request.keyScope } : {}),
288
+ ...(tools ? { [AGENT_LABELS.tools]: tools.grantId } : {}),
241
289
  },
242
290
  };
243
291
  // The daemon starts the agent once the map is ready, so agentId is null until then.
package/client/launch.tsx CHANGED
@@ -15,6 +15,7 @@ import { modeIcon, optionLabel } from "./agent-options";
15
15
  import { useKeyScope } from "./key-scope";
16
16
  import { type LaunchChoices, useLaunchChoices } from "./launch-choices";
17
17
  import { Chip, Field, Toggle } from "./launch-fields";
18
+ import { GuidanceToggles, useProjectGuidance } from "./launch-guidance";
18
19
  import { linearPatch, type TargetWorkspace, useLaunchAgent } from "./launch-plan";
19
20
  import { ModelBrowser } from "./model-browser";
20
21
  import { PickerModal } from "./pickers";
@@ -157,6 +158,7 @@ function LaunchComposer(props: LaunchPageProps & { issue: IssueDetail; settings:
157
158
  // Null until the user types. Until then the prompt follows the template and options.
158
159
  const [customPrompt, setCustomPrompt] = useState<string | null>(null);
159
160
  const { projectConfig } = choices;
161
+ const { guidance, change: changeGuidance } = useProjectGuidance(choices.project, projectConfig);
160
162
  const generated = useMemo(
161
163
  () =>
162
164
  composePrompt({
@@ -166,8 +168,9 @@ function LaunchComposer(props: LaunchPageProps & { issue: IssueDetail; settings:
166
168
  project: projectConfig,
167
169
  includeComments,
168
170
  extraInstructions: "",
171
+ guidance,
169
172
  }),
170
- [action, issue, settings, projectConfig, includeComments],
173
+ [action, issue, settings, projectConfig, includeComments, guidance],
171
174
  );
172
175
  const prompt = customPrompt ?? generated;
173
176
  const styles = useMemo(() => {
@@ -271,6 +274,7 @@ function LaunchComposer(props: LaunchPageProps & { issue: IssueDetail; settings:
271
274
  patch,
272
275
  started,
273
276
  keyScope,
277
+ guidance,
274
278
  explore: settings.live.enabled && settings.live.mapping === "explore",
275
279
  },
276
280
  {
@@ -301,6 +305,7 @@ function LaunchComposer(props: LaunchPageProps & { issue: IssueDetail; settings:
301
305
  issue,
302
306
  prompt,
303
307
  keyScope,
308
+ guidance,
304
309
  queries,
305
310
  toast,
306
311
  onStarted,
@@ -443,6 +448,10 @@ function LaunchComposer(props: LaunchPageProps & { issue: IssueDetail; settings:
443
448
  />
444
449
  ) : null}
445
450
  </View>
451
+ <View style={styles.group}>
452
+ <SectionLabel theme={theme}>Agent guidance</SectionLabel>
453
+ <GuidanceToggles theme={theme} guidance={guidance} onChange={changeGuidance} />
454
+ </View>
446
455
  {launch.error ? (
447
456
  <Text accessibilityRole="alert" style={styles.error}>
448
457
  {errorMessage(launch.error)}
@@ -1,7 +1,7 @@
1
1
  import type { PluginAgentSnapshot } from "@getpaseo/plugin";
2
2
  import { usePaseo } from "@getpaseo/plugin/client";
3
3
  import { Icon } from "@getpaseo/plugin/client/react-native";
4
- import { useQuery } from "@tanstack/react-query";
4
+ import { keepPreviousData, useQuery } from "@tanstack/react-query";
5
5
  import { useCallback, useMemo } from "react";
6
6
  import { Pressable, StyleSheet, Text, View } from "react-native";
7
7
  import {
@@ -12,9 +12,10 @@ import {
12
12
  deriveActivity,
13
13
  type FileTouch,
14
14
  } from "../shared/activity";
15
+ import type { SubagentLog } from "../shared/live";
15
16
  import type { SubagentRun } from "../shared/subagents";
16
17
  import { MONO } from "./live-issues";
17
- import { useAgentTimelines } from "./live-timeline";
18
+ import { useAgentTimelines, useSubagentLogs } from "./live-timeline";
18
19
  import { ProviderIcon } from "./provider-icon";
19
20
  import { type Theme, usePressableStyle } from "./ui";
20
21
 
@@ -22,9 +23,12 @@ import { type Theme, usePressableStyle } from "./ui";
22
23
  // have their own timelines, and provider subagents, which only report what they were asked.
23
24
 
24
25
  const CHILD_LABEL = "paseo.parent-agent-id";
25
- const MAX_CHILDREN = 4;
26
- const CHILD_COLORS = ["#e879f9", "#38bdf8", "#facc15", "#fb7185"];
26
+ const MAX_CHILDREN = 8;
27
+ const CHILD_COLORS = ["#e879f9", "#38bdf8", "#facc15", "#fb7185", "#4ade80", "#f97316", "#818cf8", "#2dd4bf"];
27
28
  const POLL_MS = 10_000;
29
+ const SUBAGENT_COLORS = ["#a78bfa", "#34d399", "#f97316", "#22d3ee", "#f472b6", "#a3e635", "#fbbf24", "#60a5fa"];
30
+ // A transcript that has not grown for this long belongs to a subagent that stopped.
31
+ const SUBAGENT_IDLE_MS = 3 * 60_000;
28
32
 
29
33
  export type AgentStatus = PluginAgentSnapshot["status"];
30
34
 
@@ -35,13 +39,49 @@ export interface ChildAgent {
35
39
  status: AgentStatus;
36
40
  cwd: string;
37
41
  color: string;
42
+ workspaceId?: string | null;
43
+ /** The issue it works on, from its Linear label or a key at the start of its title. */
44
+ issueKey?: string | null;
45
+ }
46
+
47
+ const LEADING_KEY = /^\s*([A-Za-z][A-Za-z0-9]*-\d+)\b/;
48
+
49
+ /** The issue a child agent works on, such as ENG-42 for "ENG-42: Add the form". */
50
+ export function childIssueKey(labels: Readonly<Record<string, string>> | undefined, title: string | null) {
51
+ const key = labels?.["linear.issue"] ?? LEADING_KEY.exec(title ?? "")?.[1] ?? null;
52
+ return key ? key.toUpperCase() : null;
38
53
  }
39
54
 
40
55
  export interface ChildView {
41
56
  agent: ChildAgent;
42
57
  activity: Activity | null;
43
- /** Works in the parent's folder, so its files can go on the same map. */
44
- sameFolder: boolean;
58
+ /** Works in the parent's folder or a worktree of the same project, so its paths fit the map. */
59
+ sameRepo: boolean;
60
+ /** A provider subagent read from its transcript, which Paseo cannot open as an agent. */
61
+ subagent?: boolean;
62
+ }
63
+
64
+ const runKey = (log: SubagentLog) => `${log.type}\u0000${log.description}`;
65
+ const runTitle = (type: string, description: string) => (description ? `${type}: ${description}` : type);
66
+
67
+ /** Subagents with a transcript, as views like child agents in the parent's folder. */
68
+ export function subagentViews(logs: readonly SubagentLog[], cwd: string, checkedAt: number): ChildView[] {
69
+ return logs.map((log, index) => {
70
+ const running = !log.finished && checkedAt - log.updatedAt < SUBAGENT_IDLE_MS;
71
+ return {
72
+ agent: {
73
+ id: `sub:${log.id}`,
74
+ title: runTitle(log.type, log.description),
75
+ provider: "claude",
76
+ status: running ? "running" : "idle",
77
+ cwd,
78
+ color: SUBAGENT_COLORS[index % SUBAGENT_COLORS.length] ?? "#a78bfa",
79
+ },
80
+ activity: deriveActivity(log.items, cwd),
81
+ sameRepo: true,
82
+ subagent: true,
83
+ };
84
+ });
45
85
  }
46
86
 
47
87
  export interface AgentMarker {
@@ -79,10 +119,52 @@ export function useChildAgents(agentId: string, polling: boolean): ChildAgent[]
79
119
  status: agent.status as AgentStatus,
80
120
  cwd: agent.cwd,
81
121
  color: CHILD_COLORS[index % CHILD_COLORS.length] ?? "#38bdf8",
122
+ workspaceId: agent.workspaceId ?? null,
123
+ issueKey: childIssueKey(agent.labels, agent.title ?? null),
82
124
  }));
83
125
  }, [query.data]);
84
126
  }
85
127
 
128
+ const projectOf = new Map<string, string | null>();
129
+
130
+ /** The Paseo project of each workspace. A workspace never changes project, so each is read once. */
131
+ export function useWorkspaceProjects(workspaceIds: readonly (string | null | undefined)[]) {
132
+ const paseo = usePaseo();
133
+ const ids = useMemo(
134
+ () => [...new Set(workspaceIds.filter((id): id is string => Boolean(id)))].sort(),
135
+ [workspaceIds],
136
+ );
137
+ const query = useQuery({
138
+ queryKey: ["linear", "live", "workspace-projects", ids],
139
+ queryFn: async () => {
140
+ await Promise.all(
141
+ ids
142
+ .filter((id) => !projectOf.has(id))
143
+ .map(async (id) => {
144
+ const handle = paseo.workspaces.ref(id);
145
+ const info = await handle.refresh().catch(() => null);
146
+ projectOf.set(id, info?.projectId ?? handle.projectId ?? null);
147
+ }),
148
+ );
149
+ return new Map(ids.map((id) => [id, projectOf.get(id) ?? null]));
150
+ },
151
+ enabled: ids.length > 0,
152
+ staleTime: Number.POSITIVE_INFINITY,
153
+ placeholderData: keepPreviousData,
154
+ });
155
+ return query.data;
156
+ }
157
+
158
+ /** Both workspaces belong to one Paseo project, so a worktree's paths match the parent's. */
159
+ export function sameProject(
160
+ projects: ReadonlyMap<string, string | null> | undefined,
161
+ a: string | null | undefined,
162
+ b: string | null | undefined,
163
+ ): boolean {
164
+ const first = a ? projects?.get(a) : null;
165
+ return Boolean(first) && first === (b ? projects?.get(b) : null);
166
+ }
167
+
86
168
  export function sameFolder(a: string, b: string): boolean {
87
169
  return a.replace(/\/+$/, "") === b.replace(/\/+$/, "");
88
170
  }
@@ -112,7 +194,7 @@ export function agentMarkers(input: {
112
194
  }
113
195
  for (const child of input.children) {
114
196
  const current = child.activity?.current;
115
- if (child.agent.status !== "running" || !child.sameFolder || !current) continue;
197
+ if (child.agent.status !== "running" || !child.sameRepo || !current) continue;
116
198
  add(markers, current.paths, {
117
199
  key: child.agent.id,
118
200
  kind: "child",
@@ -250,7 +332,7 @@ export function AgentsList(props: {
250
332
  detail={props.current?.text ?? null}
251
333
  live={parentRunning}
252
334
  />
253
- {props.childAgents.map(({ agent, activity, sameFolder: shared }) => (
335
+ {props.childAgents.map(({ agent, activity, sameRepo: shared, subagent: subagentView }) => (
254
336
  <AgentRow
255
337
  key={agent.id}
256
338
  theme={theme}
@@ -258,10 +340,10 @@ export function AgentsList(props: {
258
340
  icon="provider"
259
341
  provider={agent.provider}
260
342
  title={agent.title}
261
- status={STATUS_COPY[agent.status] ?? agent.status}
343
+ status={subagentView && agent.status !== "running" ? "Done" : (STATUS_COPY[agent.status] ?? agent.status)}
262
344
  detail={shared ? (activity?.current?.text ?? null) : `In ${agent.cwd.split("/").pop() ?? agent.cwd}`}
263
345
  live={agent.status === "running"}
264
- onPress={onOpenAgent ? open(agent.id) : undefined}
346
+ onPress={onOpenAgent && !subagentView ? open(agent.id) : undefined}
265
347
  />
266
348
  ))}
267
349
  {props.subagents.map((run) => (
@@ -285,7 +367,7 @@ export function AgentsList(props: {
285
367
  function mergeTouches(own: readonly FileTouch[], children: readonly ChildView[]): FileTouch[] {
286
368
  const merged = new Map(own.map((file) => [file.path, { ...file }]));
287
369
  for (const child of children) {
288
- if (!child.sameFolder) continue;
370
+ if (!child.sameRepo) continue;
289
371
  for (const file of child.activity?.files ?? []) {
290
372
  const known = merged.get(file.path);
291
373
  if (!known) {
@@ -307,26 +389,43 @@ export function useLiveAgents(input: {
307
389
  theme: Theme;
308
390
  agentId: string;
309
391
  cwd: string;
392
+ workspaceId: string | null;
310
393
  status: AgentStatus;
311
394
  provider: string;
312
395
  activity: Activity;
313
396
  }) {
314
397
  const { theme, agentId, cwd, status, provider, activity } = input;
315
398
  const children = useChildAgents(agentId, status === "running");
399
+ const projects = useWorkspaceProjects(
400
+ useMemo(() => [input.workspaceId, ...children.map((child) => child.workspaceId)], [input.workspaceId, children]),
401
+ );
316
402
  const ids = useMemo(() => children.map((child) => child.id), [children]);
317
403
  const timelines = useAgentTimelines(ids);
404
+ const logs = useSubagentLogs(
405
+ agentId,
406
+ provider.split("/")[0] === "claude" && activity.subagents.length > 0,
407
+ status === "running",
408
+ );
409
+ const subViews = useMemo(() => subagentViews(logs.runs, cwd, logs.checkedAt), [logs, cwd]);
318
410
  const views = useMemo(
319
- () =>
320
- children.map((agent): ChildView => {
411
+ () => [
412
+ ...children.map((agent): ChildView => {
321
413
  const timeline = timelines.get(agent.id);
322
414
  return {
323
415
  agent,
324
416
  activity: timeline ? deriveActivity(timeline.items, agent.cwd) : null,
325
- sameFolder: sameFolder(agent.cwd, cwd),
417
+ sameRepo: sameFolder(agent.cwd, cwd) || sameProject(projects, input.workspaceId, agent.workspaceId),
326
418
  };
327
419
  }),
328
- [children, timelines, cwd],
420
+ ...subViews,
421
+ ],
422
+ [children, timelines, cwd, subViews, projects, input.workspaceId],
329
423
  );
424
+ // Subagents with a transcript show as agents; the rest only report what they were asked.
425
+ const subagents = useMemo(() => {
426
+ const covered = new Set(logs.runs.map(runKey));
427
+ return activity.subagents.filter((run) => !covered.has(run.key));
428
+ }, [activity.subagents, logs.runs]);
330
429
  const markers = useMemo(
331
430
  () =>
332
431
  agentMarkers({
@@ -335,19 +434,27 @@ export function useLiveAgents(input: {
335
434
  working: status === "running",
336
435
  current: activity.current,
337
436
  children: views,
338
- subagents: activity.subagents,
437
+ subagents,
339
438
  }),
340
- [theme, provider, status, activity.current, activity.subagents, views],
439
+ [theme, provider, status, activity.current, subagents, views],
341
440
  );
342
441
  const files = useMemo(() => mergeTouches(activity.files, views), [activity.files, views]);
343
442
  const dirs = useMemo(
344
443
  (): DirTouch[] => [
345
444
  ...activity.dirs,
346
- ...views.flatMap((view) => (view.sameFolder ? (view.activity?.dirs ?? []) : [])),
445
+ ...views.flatMap((view) => (view.sameRepo ? (view.activity?.dirs ?? []) : [])),
347
446
  ],
348
447
  [activity.dirs, views],
349
448
  );
350
- return { children: views, markers, files, dirs };
449
+ // A subagent's row in the feed shows what it does now.
450
+ const events = useMemo(() => {
451
+ const doing = new Map(subViews.map((view) => [view.agent.title, view.activity?.current?.text ?? null]));
452
+ return activity.events.map((event) => {
453
+ const now = event.kind === "agent" ? doing.get(event.text) : undefined;
454
+ return now ? { ...event, detail: now } : event;
455
+ });
456
+ }, [activity.events, subViews]);
457
+ return { children: views, subagents, markers, files, dirs, events };
351
458
  }
352
459
 
353
460
  /** An agent that moves over the graph to what it works on. */
@@ -358,6 +465,8 @@ export interface CursorAgent {
358
465
  color: string;
359
466
  provider: string | null;
360
467
  focus: AgentFocus | null;
468
+ /** Where it hovers when its focus is not on the graph: the issue it works on. */
469
+ home: AgentFocus | null;
361
470
  running: boolean;
362
471
  /** What it does now, such as "Read page.tsx". */
363
472
  caption: string | null;
@@ -371,6 +480,8 @@ export function cursorAgents(input: {
371
480
  working: boolean;
372
481
  activity: Activity;
373
482
  children: readonly ChildView[];
483
+ /** Subagents with no cursor of their own, which circle the agent instead. */
484
+ subagents: readonly SubagentRun[];
374
485
  }): CursorAgent[] {
375
486
  const { activity } = input;
376
487
  const name = input.provider ? input.provider.charAt(0).toUpperCase() + input.provider.slice(1) : "Agent";
@@ -382,24 +493,28 @@ export function cursorAgents(input: {
382
493
  color: input.theme.colors.accent,
383
494
  provider: input.provider,
384
495
  focus: activity.focus,
496
+ home: null,
385
497
  running: input.working,
386
498
  caption: input.working ? (activity.current?.text ?? null) : null,
387
- satellites: input.working ? activity.subagents.filter((run) => run.status === "running").length : 0,
499
+ satellites: input.working ? input.subagents.filter((run) => run.status === "running").length : 0,
388
500
  },
389
501
  ];
390
502
  for (const child of input.children) {
391
503
  // Paths are relative to each agent's folder, so only agents in this folder fit the graph.
392
- if (!child.sameFolder || !child.activity) continue;
504
+ if (!child.sameRepo) continue;
393
505
  const running = child.agent.status === "running";
506
+ // Finished subagents can be many, so only working ones get a cursor.
507
+ if (child.subagent && !running) continue;
394
508
  agents.push({
395
509
  id: child.agent.id,
396
510
  kind: "child",
397
511
  label: child.agent.title,
398
512
  color: child.agent.color,
399
513
  provider: null,
400
- focus: child.activity.focus,
514
+ focus: child.activity?.focus ?? null,
515
+ home: child.agent.issueKey ? { kind: "issue", key: child.agent.issueKey } : null,
401
516
  running,
402
- caption: running ? (child.activity.current?.text ?? null) : null,
517
+ caption: running ? (child.activity?.current?.text ?? null) : null,
403
518
  satellites: 0,
404
519
  });
405
520
  }
@@ -38,6 +38,8 @@ export interface CursorProps {
38
38
  hover(id: string): Point | null;
39
39
  /** Canvas width, to keep the caption inside it. */
40
40
  width: number;
41
+ /** One over the graph zoom, so the cursor keeps its size on screen. */
42
+ inverse: Animated.AnimatedInterpolation<number>;
41
43
  reduce: boolean;
42
44
  }
43
45
 
@@ -259,7 +261,7 @@ export const AgentCursor = memo(function AgentCursor(props: CursorProps) {
259
261
  return (
260
262
  <Animated.View
261
263
  pointerEvents="none"
262
- style={[styles.anchor, { transform: [{ translateX: motion.x }, { translateY: motion.y }] }]}
264
+ style={[styles.anchor, { transform: [{ translateX: motion.x }, { translateY: motion.y }, { scale: props.inverse }] }]}
263
265
  >
264
266
  <Animated.View style={styles.float}>
265
267
  {agent.running ? <Animated.View style={styles.halo} /> : null}