@yannelli/paseo-linear-plugin 1.1.0-beta.0 → 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.
@@ -10,6 +10,7 @@ import { eventTone, KIND_ICONS } from "./live-feed";
10
10
  import { type IssueProgress, MONO, ProgressBar } from "./live-issues";
11
11
  import { type LiveMap, NATIVE_DRIVER, type OwnerColors } from "./live-timeline";
12
12
  import { ProviderIcon } from "./provider-icon";
13
+ import { ToolButton, ToolGroup } from "./tool-buttons";
13
14
  import { Button, StateIcon, type Theme } from "./ui";
14
15
 
15
16
  type AgentStatus = PluginAgentSnapshot["status"];
@@ -256,6 +257,34 @@ export function MapSource(props: { theme: Theme; live: LiveMap; mapping: Mapping
256
257
  );
257
258
  }
258
259
 
260
+ /** Read the file data again, rebuild the map with the explore agent, or clear the saved map. */
261
+ export function MapActions(props: { theme: Theme; live: LiveMap }) {
262
+ const { theme, live } = props;
263
+ const { explore, refresh, clear } = live;
264
+ const running = live.job?.status === "running";
265
+ const rebuild = useCallback(() => explore(true), [explore]);
266
+ return (
267
+ <ToolGroup theme={theme}>
268
+ <ToolButton theme={theme} icon="RefreshCw" label="Read files and links again" onPress={refresh} />
269
+ <ToolButton
270
+ theme={theme}
271
+ icon="Sparkles"
272
+ label={live.stored ? "Rebuild the map with the explore agent" : "Build the map with the explore agent"}
273
+ busy={live.starting || running}
274
+ onPress={rebuild}
275
+ />
276
+ <ToolButton
277
+ theme={theme}
278
+ icon="Eraser"
279
+ label="Clear the saved map and use the ticket text"
280
+ disabled={!live.stored || running}
281
+ busy={live.clearing}
282
+ onPress={clear}
283
+ />
284
+ </ToolGroup>
285
+ );
286
+ }
287
+
259
288
  export function ViewToggle(props: {
260
289
  theme: Theme;
261
290
  value: LiveView;
@@ -6,13 +6,13 @@ import { type LayoutChangeEvent, Text, View } from "react-native";
6
6
  import { deriveActivity } from "../shared/activity";
7
7
  import type { IssueDetail } from "../shared/linear";
8
8
  import { AGENT_LABELS } from "../shared/prompts";
9
- import { type LinearSettings, type LiveView, linearSettings, type MappingMode } from "../shared/settings";
9
+ import { type GraphCamera, type LinearSettings, type LiveView, linearSettings, type MappingMode } from "../shared/settings";
10
10
  import { KeyScopeProvider } from "./key-scope";
11
11
  import { buildGraph, graphFiles } from "../shared/graph-model";
12
12
  import { AgentsList, cursorAgents, useLiveAgents } from "./live-agents";
13
13
  import { ActivityFeed } from "./live-feed";
14
14
  import { LiveGraph } from "./live-graph";
15
- import { LiveHeader, MapSource, ViewToggle } from "./live-header";
15
+ import { LiveHeader, MapActions, MapSource, ViewToggle } from "./live-header";
16
16
  import { type FocusKey, IssueRail, issueProgress, SubIssues } from "./live-issues";
17
17
  import { MapLegend, RepoMap } from "./live-map";
18
18
  import {
@@ -33,6 +33,7 @@ interface AgentInfo {
33
33
  identifier: string | null;
34
34
  project: string | null;
35
35
  cwd: string;
36
+ workspaceId: string | null;
36
37
  status: AgentStatus;
37
38
  provider: string;
38
39
  }
@@ -41,11 +42,14 @@ const selectAgent = (agent: PluginAgentSnapshot): AgentInfo => ({
41
42
  identifier: agent.labels[AGENT_LABELS.issue] ?? null,
42
43
  project: agent.labels[AGENT_LABELS.project] ?? null,
43
44
  cwd: agent.cwd,
45
+ workspaceId: agent.workspaceId ?? null,
44
46
  status: agent.status,
45
47
  provider: agent.provider,
46
48
  });
47
49
 
48
- type LivePatch = Partial<Pick<LinearSettings["live"], "view" | "issuesCollapsed">>;
50
+ export type LivePatch = Partial<
51
+ Pick<LinearSettings["live"], "view" | "issuesCollapsed" | "graphCamera" | "graphLocked">
52
+ >;
49
53
 
50
54
  const STACK_WIDTH = 760;
51
55
  const COLUMN_WIDTH = 330;
@@ -103,6 +107,8 @@ export function LivePanel(props: PluginAgentPanelProps) {
103
107
  mapping={live?.mapping ?? "semantic"}
104
108
  view={live?.view ?? "map"}
105
109
  issuesCollapsed={live?.issuesCollapsed ?? false}
110
+ graphCamera={live?.graphCamera ?? "auto"}
111
+ graphLocked={live?.graphLocked ?? false}
106
112
  saveLive={saveLive}
107
113
  openAgent={props.navigation ? (id) => props.navigation?.openAgent({ agentId: id }) : undefined}
108
114
  />
@@ -119,6 +125,8 @@ interface BodyProps {
119
125
  mapping: MappingMode;
120
126
  view: LiveView;
121
127
  issuesCollapsed: boolean;
128
+ graphCamera: GraphCamera;
129
+ graphLocked: boolean;
122
130
  saveLive(patch: LivePatch): void;
123
131
  openAgent?: (agentId: string) => void;
124
132
  }
@@ -206,6 +214,7 @@ function LiveView(props: BodyProps & { issue: IssueDetail }) {
206
214
  theme,
207
215
  agentId,
208
216
  cwd: agent.cwd,
217
+ workspaceId: agent.workspaceId,
209
218
  status: agent.status,
210
219
  provider: agent.provider,
211
220
  activity,
@@ -249,8 +258,16 @@ function LiveView(props: BodyProps & { issue: IssueDetail }) {
249
258
  [view, graphInput, links.links],
250
259
  );
251
260
  const cursors = useMemo(
252
- () => cursorAgents({ theme, provider: agent.provider, working, activity, children: agents.children }),
253
- [theme, agent.provider, working, activity, agents.children],
261
+ () =>
262
+ cursorAgents({
263
+ theme,
264
+ provider: agent.provider,
265
+ working,
266
+ activity,
267
+ children: agents.children,
268
+ subagents: agents.subagents,
269
+ }),
270
+ [theme, agent.provider, working, activity, agents.children, agents.subagents],
254
271
  );
255
272
  const owners = useMemo(() => ownerColors(issue, colors.accent), [issue, colors.accent]);
256
273
  const progress = useMemo(() => issueProgress(issue, activity.todos), [issue, activity.todos]);
@@ -322,6 +339,7 @@ function LiveView(props: BodyProps & { issue: IssueDetail }) {
322
339
  right={
323
340
  <View style={styles.tools}>
324
341
  {stacked || view === "graph" ? null : <MapLegend theme={theme} />}
342
+ <MapActions theme={theme} live={liveMap} />
325
343
  {toggle}
326
344
  </View>
327
345
  }
@@ -346,6 +364,9 @@ function LiveView(props: BodyProps & { issue: IssueDetail }) {
346
364
  agents={cursors}
347
365
  focus={focus}
348
366
  onFocus={toggleFocus}
367
+ camera={props.graphCamera}
368
+ locked={props.graphLocked}
369
+ saveLive={saveLive}
349
370
  reduceMotion={reduceMotion}
350
371
  />
351
372
  ) : (
@@ -367,14 +388,14 @@ function LiveView(props: BodyProps & { issue: IssueDetail }) {
367
388
  <Card theme={theme} title="Activity">
368
389
  <ActivityFeed
369
390
  theme={theme}
370
- events={activity.events}
391
+ events={agents.events}
371
392
  loading={timeline.loading}
372
393
  error={timeline.error}
373
394
  reduceMotion={reduceMotion}
374
395
  />
375
396
  </Card>
376
397
  );
377
- const helpers = agents.children.length + activity.subagents.length;
398
+ const helpers = agents.children.length + agents.subagents.length;
378
399
  const agentsCard =
379
400
  helpers > 0 ? (
380
401
  <Card theme={theme} title="Agents" meta={<Text style={styles.hit}>{`${helpers + 1}`}</Text>}>
@@ -384,7 +405,7 @@ function LiveView(props: BodyProps & { issue: IssueDetail }) {
384
405
  status={agent.status}
385
406
  current={activity.current}
386
407
  childAgents={agents.children}
387
- subagents={activity.subagents}
408
+ subagents={agents.subagents}
388
409
  onOpenAgent={props.openAgent}
389
410
  />
390
411
  </Card>
@@ -1,17 +1,20 @@
1
1
  import { usePaseo, useRpc } from "@getpaseo/plugin/client";
2
- import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
3
- import { useEffect, useMemo, useState } from "react";
2
+ import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import { useCallback, useEffect, useMemo, useState } from "react";
4
4
  import { AccessibilityInfo, Platform } from "react-native";
5
5
  import type { DirTouch, FileTouch, TimelineItemLike } from "../shared/activity";
6
6
  import type { IssueDetail } from "../shared/linear";
7
7
  import type { FileLink } from "../shared/links";
8
8
  import {
9
+ clearMapRpc,
9
10
  exploreRpc,
10
11
  type IssueMap,
11
12
  type Job,
12
13
  listFilesRpc,
13
14
  linksRpc,
14
15
  liveMapRpc,
16
+ type SubagentLog,
17
+ subagentLogsRpc,
15
18
  MAX_LINK_FILES,
16
19
  MAX_LIST_DIRS,
17
20
  type MapFile,
@@ -244,6 +247,11 @@ export interface LiveMap {
244
247
  starting: boolean;
245
248
  error: string | null;
246
249
  explore(force: boolean): void;
250
+ /** Reads the map, folder listings, links, 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;
247
255
  }
248
256
 
249
257
  export function useLiveMap(
@@ -270,9 +278,13 @@ export function useLiveMap(
270
278
  onSettled: () => void refetch(),
271
279
  });
272
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 });
273
285
  const fallback = useMemo(() => semanticMap(issue), [issue]);
274
286
  const stored = query.data?.map ?? null;
275
- const failure = explore.error ?? query.error;
287
+ const failure = explore.error ?? clear.error ?? query.error;
276
288
  return {
277
289
  map: stored ?? fallback,
278
290
  stored: stored !== null,
@@ -281,6 +293,9 @@ export function useLiveMap(
281
293
  starting: explore.isPending,
282
294
  error: failure ? errorMessage(failure) : null,
283
295
  explore: mutate,
296
+ refresh,
297
+ clear: clear.mutate as () => void,
298
+ clearing: clear.isPending,
284
299
  };
285
300
  }
286
301
 
@@ -339,3 +354,23 @@ export function useFileLinks(
339
354
  ready: sorted.length === 0 || query.data !== undefined || query.isError,
340
355
  };
341
356
  }
357
+
358
+ const NO_RUNS: readonly SubagentLog[] = [];
359
+
360
+ /** Tool calls of the agent's subagents, read again every few seconds while the agent works. */
361
+ export function useSubagentLogs(
362
+ agentId: string,
363
+ enabled: boolean,
364
+ polling: boolean,
365
+ ): { runs: readonly SubagentLog[]; checkedAt: number } {
366
+ const read = useRpc(subagentLogsRpc);
367
+ const query = useQuery({
368
+ queryKey: ["linear", "live", "subagents", agentId],
369
+ queryFn: () => read({ agentId }),
370
+ enabled,
371
+ refetchInterval: polling ? 3_000 : false,
372
+ placeholderData: keepPreviousData,
373
+ staleTime: 2_000,
374
+ });
375
+ return { runs: query.data?.runs ?? NO_RUNS, checkedAt: query.dataUpdatedAt };
376
+ }
@@ -26,6 +26,7 @@ const VIEW_OPTIONS: readonly { label: string; value: LiveView }[] = [
26
26
  { label: "Graph: issue, files, and agents as connected nodes", value: "graph" },
27
27
  ];
28
28
  const SAME_AGENT = "";
29
+ const MODEL_DEFAULT = "";
29
30
 
30
31
  export function LiveSettings({ theme }: PluginSurfaceProps) {
31
32
  const settings = useSettings(linearSettings);
@@ -62,6 +63,20 @@ function LiveEditor({ theme, settings }: { theme: Theme; settings: ReadySettings
62
63
  }
63
64
  return options;
64
65
  }, [providers.data, live.exploreProvider]);
66
+ // Efforts of the chosen model, such as Low to Max, with its default first.
67
+ const effortOptions = useMemo(() => {
68
+ const [agentId, ...rest] = live.exploreProvider.split("/");
69
+ const modelId = rest.join("/");
70
+ const model = providers.data
71
+ ?.find((agent) => agent.id === agentId)
72
+ ?.models.find((entry) => entry.id === modelId);
73
+ if (!model || model.efforts.length === 0) return null;
74
+ const fallback = model.efforts.find((effort) => effort.id === model.defaultEffortId);
75
+ return [
76
+ { label: fallback ? `Model default: ${fallback.label}` : "Model default", value: MODEL_DEFAULT },
77
+ ...model.efforts.map((effort) => ({ label: effort.label, value: effort.id })),
78
+ ];
79
+ }, [providers.data, live.exploreProvider]);
65
80
 
66
81
  return (
67
82
  <View>
@@ -94,8 +109,17 @@ function LiveEditor({ theme, settings }: { theme: Theme; settings: ReadySettings
94
109
  hint="It may only read and search the project folder. A faster, cheaper model is usually enough."
95
110
  value={live.exploreProvider}
96
111
  options={agentOptions}
97
- onValueChange={(exploreProvider) => change({ exploreProvider })}
112
+ onValueChange={(exploreProvider) => change({ exploreProvider, exploreEffort: MODEL_DEFAULT })}
98
113
  />
114
+ {effortOptions ? (
115
+ <SettingsSelect
116
+ label="Explore and setup effort"
117
+ hint="How much the model thinks before it answers. Lower is faster and uses less of your plan."
118
+ value={effortOptions.some((option) => option.value === live.exploreEffort) ? live.exploreEffort : MODEL_DEFAULT}
119
+ options={effortOptions}
120
+ onValueChange={(exploreEffort) => change({ exploreEffort })}
121
+ />
122
+ ) : null}
99
123
  </SettingsCard>
100
124
  </SettingsSection>
101
125
  <SettingsSection title="Linear status sync">
@@ -0,0 +1,68 @@
1
+ import { Icon } from "@getpaseo/plugin/client/react-native";
2
+ import { type ReactNode, useMemo } from "react";
3
+ import { ActivityIndicator, Pressable, View } from "react-native";
4
+ import { type Theme, usePressableStyle } from "./ui";
5
+
6
+ // Small icon buttons for card toolbars: map actions and the graph's camera. A group draws them
7
+ // in one bordered track, like the Map and Graph switch.
8
+
9
+ export function ToolGroup({ theme, children }: { theme: Theme; children: ReactNode }) {
10
+ const { colors } = theme;
11
+ const style = useMemo(
12
+ () =>
13
+ ({
14
+ flexDirection: "row",
15
+ alignItems: "center",
16
+ padding: 2,
17
+ gap: 2,
18
+ borderRadius: 8,
19
+ borderWidth: 1,
20
+ borderColor: colors.border,
21
+ backgroundColor: colors.surface1,
22
+ }) as const,
23
+ [colors],
24
+ );
25
+ return <View style={style}>{children}</View>;
26
+ }
27
+
28
+ export function ToolButton(props: {
29
+ theme: Theme;
30
+ icon: string;
31
+ /** Read by screen readers and shown as the tooltip on web. */
32
+ label: string;
33
+ onPress(): void;
34
+ /** A toggle that is on. */
35
+ active?: boolean;
36
+ disabled?: boolean;
37
+ busy?: boolean;
38
+ }) {
39
+ const { colors } = props.theme;
40
+ const styles = useMemo(() => {
41
+ const idle = {
42
+ width: 26,
43
+ height: 24,
44
+ borderRadius: 6,
45
+ alignItems: "center",
46
+ justifyContent: "center",
47
+ backgroundColor: props.active ? colors.surface2 : "transparent",
48
+ opacity: props.disabled ? 0.4 : 1,
49
+ } as const;
50
+ return { idle, hover: { ...idle, backgroundColor: colors.surface2 } };
51
+ }, [colors, props.active, props.disabled]);
52
+ const pressStyle = usePressableStyle(styles.idle, styles.hover);
53
+ const color = props.active ? colors.foreground : colors.foregroundMuted;
54
+ return (
55
+ <Pressable
56
+ accessibilityRole="button"
57
+ accessibilityLabel={props.label}
58
+ accessibilityState={{ selected: props.active === true, disabled: props.disabled === true }}
59
+ disabled={props.disabled || props.busy}
60
+ onPress={props.onPress}
61
+ hitSlop={4}
62
+ style={pressStyle}
63
+ {...({ title: props.label } as object)}
64
+ >
65
+ {props.busy ? <ActivityIndicator size="small" color={color} /> : <Icon name={props.icon} size={14} color={color} />}
66
+ </Pressable>
67
+ );
68
+ }
package/client/web.ts ADDED
@@ -0,0 +1,45 @@
1
+ import { Platform } from "react-native";
2
+
3
+ // The only module that uses the DOM. React wheel listeners are passive, so they cannot keep
4
+ // the page from scrolling while the wheel zooms the graph. Off the web each export does nothing.
5
+
6
+ interface WheelEventLike {
7
+ deltaY: number;
8
+ deltaMode: number;
9
+ clientX: number;
10
+ clientY: number;
11
+ preventDefault(): void;
12
+ }
13
+
14
+ type WheelListener = (event: WheelEventLike) => void;
15
+
16
+ interface ElementLike {
17
+ addEventListener(type: "wheel", listener: WheelListener, options: { passive: boolean }): void;
18
+ removeEventListener(type: "wheel", listener: WheelListener): void;
19
+ getBoundingClientRect(): { left: number; top: number };
20
+ }
21
+
22
+ function isElement(node: unknown): node is ElementLike {
23
+ if (typeof node !== "object" || node === null) return false;
24
+ const element = node as Partial<ElementLike>;
25
+ return typeof element.addEventListener === "function" && typeof element.getBoundingClientRect === "function";
26
+ }
27
+
28
+ const LINE = 16;
29
+ const PAGE = 400;
30
+
31
+ /** Calls onWheel with the point under the pointer and a zoom factor. Returns the cleanup. */
32
+ export function listenWheel(
33
+ node: unknown,
34
+ onWheel: (point: { x: number; y: number }, factor: number) => void,
35
+ ): () => void {
36
+ if (Platform.OS !== "web" || !isElement(node)) return () => {};
37
+ const listener: WheelListener = (event) => {
38
+ event.preventDefault();
39
+ const rect = node.getBoundingClientRect();
40
+ const pixels = event.deltaY * (event.deltaMode === 1 ? LINE : event.deltaMode === 2 ? PAGE : 1);
41
+ onWheel({ x: event.clientX - rect.left, y: event.clientY - rect.top }, Math.exp(-pixels * 0.002));
42
+ };
43
+ node.addEventListener("wheel", listener, { passive: false });
44
+ return () => node.removeEventListener("wheel", listener);
45
+ }
package/index.server.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import path from "node:path";
2
2
  import type { PluginServerContext } from "@getpaseo/plugin/server";
3
+ import { registerAgentHooks } from "./server/agent-hooks";
4
+ import { registerAgentTools } from "./server/agent-tools";
3
5
  import { createCredentialStore, defaultCredentialPath } from "./server/credentials";
4
6
  import { registerHandlers } from "./server/handlers";
5
7
  import { registerInit } from "./server/init";
@@ -24,9 +26,11 @@ export default function contribute(server: PluginServerContext) {
24
26
  registerLive(server, { access, maps, launches, readSettings });
25
27
  registerSync(server, { access, readSettings });
26
28
  registerInit(server, { readSettings });
29
+ registerAgentHooks(server, dataDirectory);
30
+ const closeTools = registerAgentTools(server, { access, dataDirectory });
27
31
  server.handle(accessRpc, async () => {
28
32
  const values = await readSettings();
29
33
  return values?.access ?? linearSettings.schema.parse({}).access;
30
34
  });
31
- return () => {};
35
+ return closeTools;
32
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yannelli/paseo-linear-plugin",
3
- "version": "1.1.0-beta.0",
3
+ "version": "1.1.0-beta.1",
4
4
  "description": "Browse and triage Linear issues in Paseo and start agents from them",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -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
+ }
@@ -20,6 +20,8 @@ export interface InternalRun<T> {
20
20
  /** Folder the agent works in; its reads must stay inside. */
21
21
  cwd: string;
22
22
  provider: string;
23
+ /** Thinking option for the model in `provider`; ignored when another model runs. */
24
+ effort?: string;
23
25
  title: string;
24
26
  prompt: string;
25
27
  labels: Record<string, string>;
@@ -144,7 +146,7 @@ export async function agentConfigFor(paseo: Paseo, preferred: string) {
144
146
  return { provider, ...(modeId ? { modeId } : {}) };
145
147
  }
146
148
 
147
- async function lastReply(handle: AgentHandle) {
149
+ export async function lastReply(handle: AgentHandle) {
148
150
  const page = await handle.timeline.refetch({
149
151
  direction: "tail",
150
152
  limit: 60,
@@ -221,8 +223,14 @@ export interface StartedRun<T> {
221
223
  result: Promise<T>;
222
224
  }
223
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
+
224
232
  export async function startInternalRun<T>(run: InternalRun<T>): Promise<StartedRun<T>> {
225
- const config = await agentConfigFor(run.paseo, run.provider);
233
+ const config = withEffort(await agentConfigFor(run.paseo, run.provider), run.provider, run.effort);
226
234
  const handle = await run.workspace.agents.create({
227
235
  config,
228
236
  title: run.title,
@@ -239,6 +247,8 @@ export interface JobStore<T> {
239
247
  start(id: string | null, begin: () => Promise<StartedRun<T>>): Promise<Job>;
240
248
  /** Resolves with the job once it is done or failed; null for an unknown id. */
241
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;
242
252
  }
243
253
 
244
254
  interface JobRecord<T> {
@@ -254,6 +264,9 @@ export function createJobStore<T>(): JobStore<T> {
254
264
  const record = jobs.get(id);
255
265
  return record ? { job: record.job, value: record.value } : null;
256
266
  },
267
+ forget(id) {
268
+ if (jobs.get(id)?.job.status !== "running") jobs.delete(id);
269
+ },
257
270
  async wait(id) {
258
271
  const record = jobs.get(id);
259
272
  if (!record) return null;