@omercnet/paseo-omp 5.1.1-next.339.1 → 5.1.1-next.340.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.
package/OVERVIEW.md CHANGED
@@ -11,6 +11,7 @@ OMP adds an agent provider for the OMP coding agent, plus an OMP sidebar and wor
11
11
  - Edit OMP settings and project configuration for the default store or a named profile.
12
12
  - Manage OMP's own plugins, and view quota history, memory, hub processes, session history and a support report.
13
13
  - An **MCP** composer control runs OMP's MCP management in the current session, with authorization prompts shown in the chat.
14
+ - While OMP subagents run, a row under each `task` card in the conversation lets you steer one with a message or stop it after confirmation. It needs OMP 18.4.9 or newer.
14
15
  - OMP agents can call Paseo's orchestration tools with the optional `serverId` argument and `list_hosts` to delegate work to other connected Paseo hosts.
15
16
 
16
17
  ## Reads and sends
package/README.md CHANGED
@@ -31,7 +31,7 @@ paseo plugin install npm:@omercnet/paseo-omp@<version>
31
31
  paseo plugin ls paseo-omp
32
32
  ```
33
33
 
34
- Open the **OMP** sidebar to review the provider-profile contract, choose which composer pills appear, browse and edit native scalar settings, manage OMP-native plugins, verify runtime health, and generate a bounded support report under **Help**. The same tabs are available in the workspace OMP panel, except for the global Composer preference tab. Then create an agent and select **OMP Plugin**. Its optional **MCP** composer control runs OMP-native management commands in the current session; setup questions and authorization stay in that chat timeline, where OAuth can open in a workspace-scoped Paseo Browser or on the current device. Composer pill preferences are shared by clients connected to the same host; normal use requires no other plugin-specific settings.
34
+ Open the **OMP** sidebar to review the provider-profile contract, choose which composer pills appear, browse and edit native scalar settings, manage OMP-native plugins, verify runtime health, and generate a bounded support report under **Help**. The same tabs are available in the workspace OMP panel, except for the global Composer preference tab. Then create an agent and select **OMP Plugin**. Its optional **MCP** composer control runs OMP-native management commands in the current session; setup questions and authorization stay in that chat timeline, where OAuth can open in a workspace-scoped Paseo Browser or on the current device. While OMP subagents run, a row under each `task` card in the conversation lets you steer or stop one of them; this needs OMP 18.4.9 or newer. Composer pill preferences are shared by clients connected to the same host; normal use requires no other plugin-specific settings.
35
35
 
36
36
  - [Install, update, rollback, and local development](docs/installation.md)
37
37
  - [Configuration and every provider option](docs/configuration.md)
@@ -84,7 +84,7 @@ Scoring is deliberately mechanical so releases remain comparable:
84
84
  | `session.revert.both` | **0%** | Not advertised because OMP cannot atomically rewind conversation and workspace files. |
85
85
  | `session.revert.conversation` | **100%** | Native branch-based conversation rewind, replay deduplication, ownership transfer, and failure quarantine. |
86
86
  | `session.revert.files` | **0%** | Not advertised because OMP does not expose a native file-only rewind contract. |
87
- | `session.subsession` | **100%** | Direct and nested OMP subagents become Paseo child sessions with lifecycle, ancestry, progress, and replay. |
87
+ | `session.subsession` | **100%** | Direct and nested OMP subagents become Paseo child sessions with lifecycle, ancestry, progress, and replay. Paseo's child sessions are read-only, so the provider also publishes a plugin timeline row under each `task` card that steers or stops one running child through OMP's `steer_subagent` and `cancel_subagent` (OMP 18.4.9 or newer) without stopping the parent turn. |
88
88
  | `session.unarchive` | **0%** | Not advertised; there is no native OMP archive state to reverse. |
89
89
  | `permission` | **100%** | Typed tool permissions when negotiated, with a bounded generic interaction fallback for OMP 18.1.15. Allow, deny, cancel, timeout, and interruption are covered. |
90
90
  | `permission.tool_policy` | **0%** | Not advertised. Exact MCP preapproval policy cannot be preserved through OMP `set_host_tools`, so the plugin fails closed instead of broadening access. |
@@ -0,0 +1,108 @@
1
+ import type { OmpSubagentStrip } from "../shared/subagents";
2
+
3
+ export type OmpSubagentRow = OmpSubagentStrip["subagents"][number];
4
+
5
+ /**
6
+ * One row of a subagent strip at a time owns the stop confirmation, the steering draft, or the
7
+ * pending request. The state keeps that row as last listed, so its draft stays visible after the
8
+ * subagent leaves the strip.
9
+ */
10
+ export type SubagentStripState =
11
+ | { kind: "idle" }
12
+ | { kind: "confirming-stop"; subagent: OmpSubagentRow }
13
+ | { kind: "steering"; subagent: OmpSubagentRow; draft: string }
14
+ | { kind: "stopping"; subagent: OmpSubagentRow }
15
+ | { kind: "sending"; subagent: OmpSubagentRow; draft: string };
16
+
17
+ export type SubagentStripAction =
18
+ | { type: "request-stop"; subagent: OmpSubagentRow }
19
+ | { type: "start-steer"; subagent: OmpSubagentRow }
20
+ | { type: "edit-draft"; draft: string }
21
+ | { type: "dismiss" }
22
+ | { type: "submit" }
23
+ | { type: "settled" }
24
+ | { type: "failed" };
25
+
26
+ type PendingState = Extract<SubagentStripState, { kind: "stopping" | "sending" }>;
27
+
28
+ export const INITIAL_SUBAGENT_STRIP_STATE: SubagentStripState = { kind: "idle" };
29
+
30
+ export function isSendableDraft(draft: string): boolean {
31
+ return draft.trim().length > 0;
32
+ }
33
+
34
+ export function isSubagentRequestPending(state: SubagentStripState): state is PendingState {
35
+ return state.kind === "stopping" || state.kind === "sending";
36
+ }
37
+
38
+ /** The subagent a row's controls belong to while it owns the strip, or `null`. */
39
+ export function activeSubagent(state: SubagentStripState): OmpSubagentRow | null {
40
+ return state.kind === "idle" ? null : state.subagent;
41
+ }
42
+
43
+ /**
44
+ * The request a submit starts from this state, or `null` when it starts none. A submit while a
45
+ * request is pending starts nothing, so a repeated press cannot send a second steering message.
46
+ */
47
+ export function requestedSubmission(state: SubagentStripState): PendingState | null {
48
+ if (isSubagentRequestPending(state)) return null;
49
+ const next = subagentStripReducer(state, { type: "submit" });
50
+ return isSubagentRequestPending(next) ? next : null;
51
+ }
52
+
53
+ export function subagentStripReducer(
54
+ state: SubagentStripState,
55
+ action: SubagentStripAction,
56
+ ): SubagentStripState {
57
+ if (isSubagentRequestPending(state)) return settle(state, action);
58
+ switch (action.type) {
59
+ case "request-stop":
60
+ return { kind: "confirming-stop", subagent: action.subagent };
61
+ case "start-steer":
62
+ return { kind: "steering", subagent: action.subagent, draft: "" };
63
+ case "edit-draft":
64
+ return state.kind === "steering" ? { ...state, draft: action.draft } : state;
65
+ case "dismiss":
66
+ return INITIAL_SUBAGENT_STRIP_STATE;
67
+ case "submit":
68
+ if (state.kind === "confirming-stop") return { kind: "stopping", subagent: state.subagent };
69
+ if (state.kind === "steering" && isSendableDraft(state.draft)) {
70
+ return { kind: "sending", subagent: state.subagent, draft: state.draft };
71
+ }
72
+ return state;
73
+ default:
74
+ return state;
75
+ }
76
+ }
77
+
78
+ /** A failed send reopens its draft for another try; every other outcome closes the row. */
79
+ function settle(state: PendingState, action: SubagentStripAction): SubagentStripState {
80
+ if (action.type === "settled") return INITIAL_SUBAGENT_STRIP_STATE;
81
+ if (action.type !== "failed") return state;
82
+ return state.kind === "sending"
83
+ ? { kind: "steering", subagent: state.subagent, draft: state.draft }
84
+ : INITIAL_SUBAGENT_STRIP_STATE;
85
+ }
86
+
87
+ export function stopOutcomeMessage(cancelled: boolean): string {
88
+ return cancelled ? "Subagent stopped" : "The subagent had already finished";
89
+ }
90
+
91
+ /** The host's RPC error keeps the handler's text in `userMessage`, without transport details. */
92
+ export function subagentRequestError(cause: unknown): string {
93
+ if (cause instanceof Error) {
94
+ const userMessage: unknown = Reflect.get(cause, "userMessage");
95
+ return typeof userMessage === "string" && userMessage ? userMessage : cause.message;
96
+ }
97
+ return "The OMP subagent request failed";
98
+ }
99
+
100
+ /** Compact elapsed time, such as `42s`, `3m`, or `1h 5m`. */
101
+ export function formatElapsed(milliseconds: number): string {
102
+ const seconds = Math.max(0, Math.floor(milliseconds / 1_000));
103
+ if (seconds < 60) return `${seconds}s`;
104
+ const minutes = Math.floor(seconds / 60);
105
+ if (minutes < 60) return `${minutes}m`;
106
+ const hours = Math.floor(minutes / 60);
107
+ return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`;
108
+ }
@@ -0,0 +1,391 @@
1
+ import {
2
+ type PluginHostProps,
3
+ type PluginTimelineItemProps,
4
+ useRpc,
5
+ } from "@getpaseo/plugin/client";
6
+ import { Icon, TextInput, useToast } from "@getpaseo/plugin/client/ui";
7
+ import { useEffect, useMemo, useReducer, useState } from "react";
8
+ import { Pressable, Text, View } from "react-native";
9
+ import {
10
+ cancelOmpSubagent,
11
+ OMP_SUBAGENT_STEER_MAX_LENGTH,
12
+ type OmpSubagentStrip as OmpSubagentStripData,
13
+ steerOmpSubagent,
14
+ } from "../shared/subagents";
15
+ import {
16
+ activeSubagent,
17
+ formatElapsed,
18
+ INITIAL_SUBAGENT_STRIP_STATE,
19
+ isSendableDraft,
20
+ isSubagentRequestPending,
21
+ type OmpSubagentRow,
22
+ requestedSubmission,
23
+ type SubagentStripAction,
24
+ type SubagentStripState,
25
+ stopOutcomeMessage,
26
+ subagentRequestError,
27
+ subagentStripReducer,
28
+ } from "./subagents-state";
29
+
30
+ const ROW_HEIGHT = 28;
31
+
32
+ function createStyles(theme: PluginHostProps["theme"], compact: boolean) {
33
+ return {
34
+ root: { gap: 2, paddingVertical: 2 },
35
+ row: {
36
+ flexDirection: "row" as const,
37
+ alignItems: "center" as const,
38
+ gap: 8,
39
+ height: ROW_HEIGHT,
40
+ paddingLeft: compact ? 2 : 4,
41
+ },
42
+ dot: { width: 6, height: 6, borderRadius: 3, backgroundColor: theme.colors.accent },
43
+ dotFinished: { backgroundColor: theme.colors.foregroundMuted },
44
+ agent: { color: theme.colors.foreground, fontSize: 13, flexShrink: 0 },
45
+ description: { color: theme.colors.foregroundMuted, fontSize: 13, flexShrink: 1, minWidth: 0 },
46
+ elapsed: {
47
+ color: theme.colors.foregroundMuted,
48
+ fontSize: 12,
49
+ fontVariant: ["tabular-nums" as const],
50
+ },
51
+ actions: { flexDirection: "row" as const, alignItems: "center" as const, gap: 2 },
52
+ iconButton: {
53
+ width: 24,
54
+ height: 24,
55
+ borderRadius: 6,
56
+ alignItems: "center" as const,
57
+ justifyContent: "center" as const,
58
+ },
59
+ textButton: {
60
+ height: 24,
61
+ paddingHorizontal: 8,
62
+ borderRadius: 6,
63
+ justifyContent: "center" as const,
64
+ },
65
+ hovered: { backgroundColor: theme.colors.surface2 },
66
+ disabled: { opacity: 0.5 },
67
+ stopLabel: { color: theme.colors.statusDanger, fontSize: 12 },
68
+ keepLabel: { color: theme.colors.foregroundMuted, fontSize: 12 },
69
+ tooltip: {
70
+ position: "absolute" as const,
71
+ bottom: 28,
72
+ right: 0,
73
+ paddingHorizontal: 6,
74
+ paddingVertical: 2,
75
+ borderRadius: 6,
76
+ backgroundColor: theme.colors.surface2,
77
+ borderWidth: 1,
78
+ borderColor: theme.colors.border,
79
+ },
80
+ tooltipText: { color: theme.colors.foreground, fontSize: 11 },
81
+ input: {
82
+ flexBasis: 360,
83
+ flexShrink: 1,
84
+ minWidth: 0,
85
+ height: 24,
86
+ paddingHorizontal: 8,
87
+ borderRadius: 6,
88
+ borderWidth: 1,
89
+ borderColor: theme.colors.border,
90
+ backgroundColor: theme.colors.surface1,
91
+ color: theme.colors.foreground,
92
+ fontSize: 13,
93
+ },
94
+ };
95
+ }
96
+
97
+ type Styles = ReturnType<typeof createStyles>;
98
+
99
+ /** Re-renders once per second while `active`, for elapsed times. */
100
+ function useSecondTick(active: boolean): number {
101
+ const [now, setNow] = useState(() => Date.now());
102
+ useEffect(() => {
103
+ if (!active) return;
104
+ const timer = setInterval(() => setNow(Date.now()), 1_000);
105
+ return () => clearInterval(timer);
106
+ }, [active]);
107
+ return now;
108
+ }
109
+
110
+ /**
111
+ * Running OMP subagents of one `task` call, published by the provider below its tool card. Paseo
112
+ * shows provider subagents read-only, so the plugin adds steer and stop here.
113
+ */
114
+ export function OmpSubagentStrip({
115
+ agentId,
116
+ item,
117
+ theme,
118
+ layout,
119
+ }: PluginTimelineItemProps<OmpSubagentStripData>) {
120
+ const cancel = useRpc(cancelOmpSubagent);
121
+ const steer = useRpc(steerOmpSubagent);
122
+ const toast = useToast();
123
+ const styles = useMemo(() => createStyles(theme, layout.compact), [theme, layout.compact]);
124
+ const [state, dispatch] = useReducer(subagentStripReducer, INITIAL_SUBAGENT_STRIP_STATE);
125
+ const listed = item.data.subagents;
126
+ const active = activeSubagent(state);
127
+ const unlisted = active && !listed.some((row) => row.id === active.id) ? active : null;
128
+ const now = useSecondTick(listed.length > 0);
129
+
130
+ async function submit(): Promise<void> {
131
+ const request = requestedSubmission(state);
132
+ dispatch({ type: "submit" });
133
+ if (!request) return;
134
+ try {
135
+ if (request.kind === "stopping") {
136
+ const result = await cancel({ agentId, subagentId: request.subagent.id });
137
+ toast.show(stopOutcomeMessage(result.cancelled), {
138
+ variant: result.cancelled ? "success" : "default",
139
+ });
140
+ } else {
141
+ await steer({ agentId, subagentId: request.subagent.id, message: request.draft });
142
+ toast.show("Message sent to the subagent", { variant: "success" });
143
+ }
144
+ dispatch({ type: "settled" });
145
+ } catch (cause) {
146
+ toast.error(subagentRequestError(cause));
147
+ dispatch({ type: "failed" });
148
+ }
149
+ }
150
+
151
+ if (listed.length === 0 && !unlisted) return null;
152
+ return (
153
+ <View style={styles.root}>
154
+ {listed.map((row) => (
155
+ <SubagentRow
156
+ key={row.id}
157
+ row={row}
158
+ listed
159
+ now={now}
160
+ state={state}
161
+ styles={styles}
162
+ theme={theme}
163
+ dispatch={dispatch}
164
+ submit={submit}
165
+ />
166
+ ))}
167
+ {unlisted ? (
168
+ <SubagentRow
169
+ row={unlisted}
170
+ listed={false}
171
+ now={now}
172
+ state={state}
173
+ styles={styles}
174
+ theme={theme}
175
+ dispatch={dispatch}
176
+ submit={submit}
177
+ />
178
+ ) : null}
179
+ </View>
180
+ );
181
+ }
182
+
183
+ interface RowProps {
184
+ row: OmpSubagentRow;
185
+ /** False keeps the row that owns a draft visible after its subagent leaves the strip. */
186
+ listed: boolean;
187
+ now: number;
188
+ state: SubagentStripState;
189
+ styles: Styles;
190
+ theme: PluginHostProps["theme"];
191
+ dispatch: (action: SubagentStripAction) => void;
192
+ submit(): Promise<void>;
193
+ }
194
+
195
+ function SubagentRow(props: RowProps) {
196
+ const { row, listed, now, state, styles } = props;
197
+ const owner = activeSubagent(state)?.id === row.id ? state : null;
198
+ const steering = owner?.kind === "steering" || owner?.kind === "sending";
199
+ return (
200
+ <View style={styles.row}>
201
+ <View style={[styles.dot, listed ? null : styles.dotFinished]} />
202
+ <Text numberOfLines={1} style={styles.agent}>
203
+ {row.agent}
204
+ </Text>
205
+ {steering ? (
206
+ <SteerControls {...props} />
207
+ ) : (
208
+ <>
209
+ <Text numberOfLines={1} style={styles.description}>
210
+ {row.description ?? ""}
211
+ </Text>
212
+ {owner?.kind === "confirming-stop" || owner?.kind === "stopping" ? (
213
+ <StopConfirmation {...props} />
214
+ ) : (
215
+ <>
216
+ <Text style={styles.elapsed}>
217
+ {listed ? formatElapsed(now - row.runningSince) : "finished"}
218
+ </Text>
219
+ {listed ? <RowActions {...props} /> : null}
220
+ </>
221
+ )}
222
+ </>
223
+ )}
224
+ </View>
225
+ );
226
+ }
227
+
228
+ function RowActions({ row, state, styles, theme, dispatch }: RowProps) {
229
+ const pending = isSubagentRequestPending(state);
230
+ return (
231
+ <View style={styles.actions}>
232
+ <IconButton
233
+ icon="MessageSquare"
234
+ label="Steer: send this subagent a message at its next step"
235
+ disabled={pending}
236
+ styles={styles}
237
+ theme={theme}
238
+ onPress={() => dispatch({ type: "start-steer", subagent: row })}
239
+ />
240
+ <IconButton
241
+ icon="CircleStop"
242
+ label="Stop this subagent and its nested subagents; the parent keeps running"
243
+ disabled={pending}
244
+ styles={styles}
245
+ theme={theme}
246
+ onPress={() => dispatch({ type: "request-stop", subagent: row })}
247
+ />
248
+ </View>
249
+ );
250
+ }
251
+
252
+ function StopConfirmation({ state, styles, dispatch, submit }: RowProps) {
253
+ const stopping = state.kind === "stopping";
254
+ return (
255
+ <View style={styles.actions}>
256
+ <TextButton
257
+ label={stopping ? "Stopping..." : "Stop"}
258
+ accessibilityLabel="Confirm stopping this subagent"
259
+ labelStyle={styles.stopLabel}
260
+ disabled={stopping}
261
+ styles={styles}
262
+ onPress={() => void submit()}
263
+ />
264
+ <TextButton
265
+ label="Keep"
266
+ accessibilityLabel="Keep this subagent running"
267
+ labelStyle={styles.keepLabel}
268
+ disabled={stopping}
269
+ styles={styles}
270
+ onPress={() => dispatch({ type: "dismiss" })}
271
+ />
272
+ </View>
273
+ );
274
+ }
275
+
276
+ function SteerControls({ row, state, styles, theme, dispatch, submit }: RowProps) {
277
+ const sending = state.kind === "sending";
278
+ const draft = state.kind === "steering" || state.kind === "sending" ? state.draft : "";
279
+ return (
280
+ <>
281
+ <TextInput
282
+ accessibilityLabel={`Message for ${row.agent}`}
283
+ autoFocus
284
+ editable={!sending}
285
+ maxLength={OMP_SUBAGENT_STEER_MAX_LENGTH}
286
+ onChangeText={(text) => dispatch({ type: "edit-draft", draft: text })}
287
+ onKeyPress={(event) => {
288
+ if (event.nativeEvent.key === "Escape") dispatch({ type: "dismiss" });
289
+ }}
290
+ onSubmitEditing={() => void submit()}
291
+ placeholder="Message the subagent"
292
+ placeholderTextColor={theme.colors.foregroundMuted}
293
+ style={styles.input}
294
+ value={draft}
295
+ />
296
+ <View style={styles.actions}>
297
+ <IconButton
298
+ icon="SendHorizontal"
299
+ label="Send to the subagent (Enter)"
300
+ disabled={sending || !isSendableDraft(draft)}
301
+ styles={styles}
302
+ theme={theme}
303
+ onPress={() => void submit()}
304
+ />
305
+ <IconButton
306
+ icon="X"
307
+ label="Close (Esc)"
308
+ disabled={sending}
309
+ styles={styles}
310
+ theme={theme}
311
+ onPress={() => dispatch({ type: "dismiss" })}
312
+ />
313
+ </View>
314
+ </>
315
+ );
316
+ }
317
+
318
+ function IconButton({
319
+ icon,
320
+ label,
321
+ disabled,
322
+ styles,
323
+ theme,
324
+ onPress,
325
+ }: {
326
+ icon: string;
327
+ label: string;
328
+ disabled: boolean;
329
+ styles: Styles;
330
+ theme: PluginHostProps["theme"];
331
+ onPress(): void;
332
+ }) {
333
+ const [hovered, setHovered] = useState(false);
334
+ return (
335
+ <View>
336
+ <Pressable
337
+ accessibilityRole="button"
338
+ accessibilityLabel={label}
339
+ disabled={disabled}
340
+ onHoverIn={() => setHovered(true)}
341
+ onHoverOut={() => setHovered(false)}
342
+ onPress={onPress}
343
+ style={[
344
+ styles.iconButton,
345
+ hovered && !disabled ? styles.hovered : null,
346
+ disabled ? styles.disabled : null,
347
+ ]}
348
+ >
349
+ <Icon name={icon} size={14} color={theme.colors.foregroundMuted} />
350
+ </Pressable>
351
+ {hovered && !disabled ? (
352
+ <View pointerEvents="none" style={styles.tooltip}>
353
+ <Text numberOfLines={1} style={styles.tooltipText}>
354
+ {label}
355
+ </Text>
356
+ </View>
357
+ ) : null}
358
+ </View>
359
+ );
360
+ }
361
+
362
+ function TextButton({
363
+ label,
364
+ accessibilityLabel,
365
+ labelStyle,
366
+ disabled,
367
+ styles,
368
+ onPress,
369
+ }: {
370
+ label: string;
371
+ accessibilityLabel: string;
372
+ labelStyle: Styles["stopLabel"];
373
+ disabled: boolean;
374
+ styles: Styles;
375
+ onPress(): void;
376
+ }) {
377
+ const [hovered, setHovered] = useState(false);
378
+ return (
379
+ <Pressable
380
+ accessibilityRole="button"
381
+ accessibilityLabel={accessibilityLabel}
382
+ disabled={disabled}
383
+ onHoverIn={() => setHovered(true)}
384
+ onHoverOut={() => setHovered(false)}
385
+ onPress={onPress}
386
+ style={[styles.textButton, hovered && !disabled ? styles.hovered : null]}
387
+ >
388
+ <Text style={labelStyle}>{label}</Text>
389
+ </Pressable>
390
+ );
391
+ }
package/index.client.tsx CHANGED
@@ -38,6 +38,7 @@ import {
38
38
  HUB_SIDEBAR_ITEM_ID,
39
39
  loadHubSnapshot,
40
40
  } from "./client/sidebar-compat";
41
+ import { OmpSubagentStrip } from "./client/subagents-strip";
41
42
  import {
42
43
  type ComposerPillSettings,
43
44
  composerPillSettings,
@@ -48,6 +49,7 @@ import { OMP_MCP_AUTH_TIMELINE_KIND, ompMcpAuthorizationTimelineSchema } from ".
48
49
  import { type OmpStore, storeForProvider } from "./shared/omp-store";
49
50
  import { ompImageTimelineSchema, transformOmpImageToolItem } from "./shared/provider-image";
50
51
  import { listOmpQuotas } from "./shared/quota";
52
+ import { OMP_SUBAGENT_STRIP_KIND, ompSubagentStripSchema } from "./shared/subagents";
51
53
 
52
54
  const PAGE_LIMIT = 200;
53
55
  const MAX_PAGES = 10;
@@ -237,6 +239,12 @@ export default function contribute(client: PluginClientContext) {
237
239
  schema: ompMcpAuthorizationTimelineSchema,
238
240
  Component: OmpMcpAuthorizationCard,
239
241
  });
242
+ const removeSubagentStripRenderer = client.addTimelineRenderer({
243
+ kind: OMP_SUBAGENT_STRIP_KIND,
244
+ version: 1,
245
+ schema: ompSubagentStripSchema,
246
+ Component: OmpSubagentStrip,
247
+ });
240
248
  const removeImageTransformer = client.addTimelineTransformer({
241
249
  id: "omp-images",
242
250
  query: { itemType: "tool_call" },
@@ -544,6 +552,7 @@ export default function contribute(client: PluginClientContext) {
544
552
  pills.clear();
545
553
  removeImageRenderer();
546
554
  removeMcpAuthorizationRenderer();
555
+ removeSubagentStripRenderer();
547
556
  removeImageTransformer();
548
557
  removeOpenConfig();
549
558
  removeConfigEntry();
package/index.server.ts CHANGED
@@ -29,6 +29,11 @@ import { OmpSessionLimit } from "./server/provider/session-limit";
29
29
  import { resolveGetOmpProviderHealth } from "./server/provider-diagnostics";
30
30
  import { resolveListOmpQuotas } from "./server/quota";
31
31
  import { resolveListOmpSessions } from "./server/sessions";
32
+ import {
33
+ OmpSubagentControlRegistry,
34
+ resolveCancelOmpSubagent,
35
+ resolveSteerOmpSubagent,
36
+ } from "./server/subagents";
32
37
  import { resolveGetOmpSupportReport } from "./server/support-diagnostics";
33
38
  import { composerPillSettings } from "./shared/composer-pill-settings";
34
39
  import { listHubProcesses, tailHubLog } from "./shared/hub";
@@ -48,6 +53,7 @@ import { getOmpProviderHealth } from "./shared/provider-diagnostics";
48
53
  import { providerLaunchSettings } from "./shared/provider-launch-settings";
49
54
  import { listOmpQuotas } from "./shared/quota";
50
55
  import { listOmpSessions } from "./shared/sessions";
56
+ import { cancelOmpSubagent, steerOmpSubagent } from "./shared/subagents";
51
57
  import { getOmpSupportReport } from "./shared/support-diagnostics";
52
58
 
53
59
  function scoped<T extends { store?: OmpStore }, R>(handler: (input: T) => R) {
@@ -68,6 +74,7 @@ export default function contribute(server: PluginServerContext) {
68
74
  return current.status === "ready" ? current.values.maxSessions : undefined;
69
75
  });
70
76
  const removeSessionLimitListener = launchSettings.subscribe(() => sessionLimit.refresh());
77
+ const subagentControlRegistry = new OmpSubagentControlRegistry();
71
78
  const protocolViolations = new OmpProtocolViolationCollector();
72
79
  const operationalFailures = new OmpOperationalFailureCollector();
73
80
  const profiles = discoverOmpProfilesSync();
@@ -77,6 +84,7 @@ export default function contribute(server: PluginServerContext) {
77
84
  createProfileOmpProvider(profile, {
78
85
  browserAuthorizationRegistry,
79
86
  sessionLimit,
87
+ subagentControlRegistry,
80
88
  reportProtocolViolation: protocolViolations.report,
81
89
  reportOperationalFailure: operationalFailures.report,
82
90
  resolveHostInheritEnv,
@@ -104,6 +112,12 @@ export default function contribute(server: PluginServerContext) {
104
112
  server.handle(openOmpMcpAuthorizationInPaseoBrowser, (input) =>
105
113
  resolveOpenOmpMcpAuthorizationInPaseoBrowser(input, browserAuthorizationRegistry),
106
114
  );
115
+ server.handle(cancelOmpSubagent, (input) =>
116
+ resolveCancelOmpSubagent(input, subagentControlRegistry),
117
+ );
118
+ server.handle(steerOmpSubagent, (input) =>
119
+ resolveSteerOmpSubagent(input, subagentControlRegistry),
120
+ );
107
121
  const removeSessionOpenHook = server.before("agent.session_open", ({ request }) => {
108
122
  if (request.provider !== "omp-plugin" && !request.provider.startsWith("omp-plugin-")) return;
109
123
  return withOmpSessionOpenEnv(request);
@@ -112,6 +126,7 @@ export default function contribute(server: PluginServerContext) {
112
126
  createOmpProvider({
113
127
  browserAuthorizationRegistry,
114
128
  sessionLimit,
129
+ subagentControlRegistry,
115
130
  reportProtocolViolation: protocolViolations.report,
116
131
  reportOperationalFailure: operationalFailures.report,
117
132
  resolveHostInheritEnv,
@@ -120,6 +135,7 @@ export default function contribute(server: PluginServerContext) {
120
135
  return () => {
121
136
  browserAuthorizationRegistry.clear();
122
137
  removeSessionLimitListener();
138
+ subagentControlRegistry.clear();
123
139
  removeSessionOpenHook();
124
140
  };
125
141
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omercnet/paseo-omp",
3
- "version": "5.1.1-next.339.1",
3
+ "version": "5.1.1-next.340.1",
4
4
  "type": "module",
5
5
  "description": "Paseo integration for OMP, including its direct provider and workspace tooling.",
6
6
  "license": "MIT",
@@ -1,2 +1,2 @@
1
1
  /** Release Please keeps this bundle-safe constant aligned with package.json. */
2
- export const PASEO_OMP_PACKAGE_VERSION = "5.1.1-next.339.1"; // x-release-please-version
2
+ export const PASEO_OMP_PACKAGE_VERSION = "5.1.1-next.340.1"; // x-release-please-version
@@ -13,6 +13,7 @@ import type {
13
13
  OmpOperationalFailure,
14
14
  OmpOperationalFailureReporter,
15
15
  } from "../operational-failure-diagnostics";
16
+ import type { OmpSubagentControlRegistry } from "../subagents";
16
17
  import { discoverOmpCatalog } from "./catalog";
17
18
  import { normalizeOmpCatalogOptions } from "./config-normalization";
18
19
  import type { OmpMcpConnector } from "./host-tools";
@@ -724,6 +725,7 @@ export function createOmpConnection(
724
725
  reportOperationalFailure: OmpOperationalFailureReporter = () => {},
725
726
  resolveHostInheritEnv?: () => Promise<readonly string[]>,
726
727
  sessionLimit = new OmpSessionLimit(),
728
+ subagentControlRegistry?: OmpSubagentControlRegistry,
727
729
  ): ProviderConnection {
728
730
  const errorDetails = (error: unknown, fallback: string): { message: string } => {
729
731
  if (isOmpPublicError(error)) return { message: error.message };
@@ -758,6 +760,7 @@ export function createOmpConnection(
758
760
  session: OmpOpenedSession;
759
761
  nativeSessionId?: string;
760
762
  removeBrowserAuthorization?: () => void;
763
+ removeSubagentControls?: () => void;
761
764
  }
762
765
  >();
763
766
  const opening = new Map<
@@ -791,6 +794,7 @@ export function createOmpConnection(
791
794
  if (slot?.token !== token) return false;
792
795
  sessions.delete(sessionId);
793
796
  slot.removeBrowserAuthorization?.();
797
+ slot.removeSubagentControls?.();
794
798
  return true;
795
799
  };
796
800
 
@@ -1033,12 +1037,12 @@ export function createOmpConnection(
1033
1037
  nativeReservations.release(nativeSessionId, token);
1034
1038
  return;
1035
1039
  }
1036
- const browserAgentId = input.config.env.PASEO_AGENT_ID?.trim() || input.sessionId;
1040
+ const agentId = input.config.env.PASEO_AGENT_ID?.trim() || input.sessionId;
1037
1041
  // A history session has no live Paseo browser tools to authorize.
1038
1042
  const browserAuthorization = historyOnly
1039
1043
  ? undefined
1040
1044
  : browserAuthorizationRegistry?.register(
1041
- browserAgentId,
1045
+ agentId,
1042
1046
  session.openPaseoBrowser.bind(session),
1043
1047
  );
1044
1048
  session.setBrowserAuthorizationIssuer(browserAuthorization?.issue ?? null);
@@ -1048,11 +1052,17 @@ export function createOmpConnection(
1048
1052
  browserAuthorization.remove();
1049
1053
  }
1050
1054
  : undefined;
1055
+ // A history session has no runtime, so it has no subagents to control.
1056
+ const removeSubagentControls =
1057
+ session instanceof OmpProviderSession
1058
+ ? subagentControlRegistry?.register(agentId, session)
1059
+ : undefined;
1051
1060
  sessions.set(input.sessionId, {
1052
1061
  token,
1053
1062
  session,
1054
1063
  nativeSessionId,
1055
1064
  ...(removeBrowserAuthorization ? { removeBrowserAuthorization } : {}),
1065
+ ...(removeSubagentControls ? { removeSubagentControls } : {}),
1056
1066
  });
1057
1067
  await session.publishOpened(input.requestId);
1058
1068
  if (
@@ -1204,7 +1214,10 @@ export function createOmpConnection(
1204
1214
  shutdown.abort(shutdownReason);
1205
1215
  for (const { controller } of opening.values()) controller.abort(shutdownReason);
1206
1216
  for (const { session } of sessions.values()) session.beginConnectionShutdown();
1207
- for (const slot of sessions.values()) slot.removeBrowserAuthorization?.();
1217
+ for (const slot of sessions.values()) {
1218
+ slot.removeBrowserAuthorization?.();
1219
+ slot.removeSubagentControls?.();
1220
+ }
1208
1221
  const failures: unknown[] = [];
1209
1222
  const operationBatch = [...activeOperations];
1210
1223
  const operationResults = await Promise.allSettled(operationBatch);
@@ -144,7 +144,7 @@ export const OMP_RPC_COMMAND_POLICIES = {
144
144
  abort_and_restore_queue: "unsupported",
145
145
  btw: "unsupported",
146
146
  btw_cancel: "unsupported",
147
- cancel_subagent: "unsupported",
147
+ cancel_subagent: "implemented",
148
148
  fork: "unsupported",
149
149
  get_btw_history: "unsupported",
150
150
  get_logout_accounts: "unsupported",
@@ -166,7 +166,7 @@ export const OMP_RPC_COMMAND_POLICIES = {
166
166
  set_ask_dialog: "implemented",
167
167
  set_cache_warming: "unsupported",
168
168
  set_slow_mode: "unsupported",
169
- steer_subagent: "unsupported",
169
+ steer_subagent: "implemented",
170
170
  } as const;
171
171
  export const OMP_RPC_RESPONSE_POLICIES = OMP_RPC_COMMAND_POLICIES;
172
172
  export const OMP_PROTOCOL_VIOLATION_CATEGORIES = [
@@ -2001,6 +2001,8 @@ export const OmpSubagentsResultSchema = z.object({
2001
2001
  )
2002
2002
  .max(1_024),
2003
2003
  });
2004
+ export const OmpCancelSubagentResultSchema = z.object({ cancelled: z.boolean() });
2005
+ export const OmpSteerSubagentResultSchema = z.object({}).strict().optional();
2004
2006
  export const OmpSubagentMessagesResultSchema = z.object({
2005
2007
  sessionFile: boundedString(MAX_PATH_LENGTH),
2006
2008
  fromByte: z.number().int().nonnegative(),
@@ -16,6 +16,7 @@ import {
16
16
  OmpBranchMessagesResultSchema,
17
17
  type OmpBranchResult,
18
18
  OmpBranchResultSchema,
19
+ OmpCancelSubagentResultSchema,
19
20
  type OmpCompactionResult,
20
21
  OmpCompactionResultSchema,
21
22
  type OmpExtensionUiResponse,
@@ -41,6 +42,7 @@ import {
41
42
  OmpSessionStateSchema,
42
43
  type OmpSessionStats,
43
44
  OmpSessionStatsSchema,
45
+ OmpSteerSubagentResultSchema,
44
46
  type OmpSubagentMessagesResult,
45
47
  OmpSubagentMessagesResultSchema,
46
48
  type OmpSubagentSnapshot,
@@ -105,6 +107,9 @@ export interface OmpRuntimeSession {
105
107
  subagentId?: string;
106
108
  sessionFile?: string;
107
109
  }): Promise<OmpSubagentMessagesResult>;
110
+ /** Resolves `false` when OMP no longer lists the subagent as running for this session. */
111
+ cancelSubagent(subagentId: string): Promise<boolean>;
112
+ steerSubagent(subagentId: string, message: string): Promise<void>;
108
113
  prompt(
109
114
  message: string,
110
115
  images?: readonly OmpImage[],
@@ -466,6 +471,26 @@ class OmpRpcSession implements OmpRuntimeSession {
466
471
  );
467
472
  }
468
473
 
474
+ async cancelSubagent(subagentId: string): Promise<boolean> {
475
+ const safeId = validateBoundedText(subagentId, "subagent identifier", MAX_ID_LENGTH);
476
+ return OmpCancelSubagentResultSchema.parse(
477
+ await this.process.request({ type: "cancel_subagent", subagentId: safeId }),
478
+ ).cancelled;
479
+ }
480
+
481
+ async steerSubagent(subagentId: string, message: string): Promise<void> {
482
+ const safeId = validateBoundedText(subagentId, "subagent identifier", MAX_ID_LENGTH);
483
+ const safeMessage = validateBoundedText(message, "subagent steering message", MAX_TEXT_LENGTH);
484
+ if (!safeMessage.trim()) throw new Error("Invalid OMP subagent steering message");
485
+ OmpSteerSubagentResultSchema.parse(
486
+ await this.process.request({
487
+ type: "steer_subagent",
488
+ subagentId: safeId,
489
+ message: safeMessage,
490
+ }),
491
+ );
492
+ }
493
+
469
494
  async getBranchMessages(): Promise<Array<{ entryId: string; text: string }>> {
470
495
  const result = OmpBranchMessagesResultSchema.parse(
471
496
  await this.process.request({ type: "get_branch_messages" }),
@@ -5,6 +5,7 @@ import { z } from "zod";
5
5
  import type { OmpBrowserAuthorizationRegistry } from "../mcp-browser";
6
6
  import type { OmpOperationalFailureReporter } from "../operational-failure-diagnostics";
7
7
  import { probeOmpAvailability } from "../provider-diagnostics";
8
+ import type { OmpSubagentControlRegistry } from "../subagents";
8
9
  import { mergeOmpInheritedEnvironmentNames } from "./config-normalization";
9
10
  import { createOmpConnection, OmpNativeSessionReservations } from "./connection";
10
11
  import type { OmpMcpConnector } from "./host-tools";
@@ -131,6 +132,7 @@ export interface OmpProviderOptions {
131
132
  timeoutMs: number | undefined,
132
133
  ) => Promise<ProviderAvailabilityCompat>;
133
134
  resolveHostInheritEnv?: () => Promise<readonly string[]>;
135
+ subagentControlRegistry?: OmpSubagentControlRegistry;
134
136
  }
135
137
 
136
138
  function stableJson(value: unknown): string {
@@ -235,6 +237,7 @@ export function createOmpProvider(options: OmpProviderOptions = {}): ProviderReg
235
237
  options.reportOperationalFailure,
236
238
  options.resolveHostInheritEnv,
237
239
  sessionLimit,
240
+ options.subagentControlRegistry,
238
241
  );
239
242
  },
240
243
  };
@@ -6,10 +6,12 @@ import type {
6
6
  ProviderInput,
7
7
  ProviderSessionConfig,
8
8
  } from "@getpaseo/plugin/server/provider";
9
+ import { isLiveOmpSubagentStatus } from "../../shared/subagents";
9
10
  import type {
10
11
  OmpOperationalFailure,
11
12
  OmpOperationalFailureReporter,
12
13
  } from "../operational-failure-diagnostics";
14
+ import { type OmpSubagentControls, OmpSubagentNotRunningError } from "../subagents";
13
15
  import {
14
16
  mapOmpModels,
15
17
  nativeOmpModelId,
@@ -84,7 +86,7 @@ import {
84
86
  unknownTerminalOutcomeError,
85
87
  } from "./session-terminal";
86
88
  import { OmpSessionUsage } from "./session-usage";
87
- import { OmpSubsessionProjector } from "./subsessions";
89
+ import { OmpSubsessionProjector, ompSubagentHandle } from "./subsessions";
88
90
  import {
89
91
  defaultOmpTimelineScheduler,
90
92
  OmpTimelineProjector,
@@ -298,7 +300,7 @@ async function settleSessionCleanup(promises: readonly Promise<void>[]): Promise
298
300
  throw new AggregateError(failures, "OMP session initialization cleanup failed");
299
301
  }
300
302
  }
301
- export class OmpProviderSession implements OmpOpenedSession {
303
+ export class OmpProviderSession implements OmpOpenedSession, OmpSubagentControls {
302
304
  readonly id: string;
303
305
  readonly cwd: string;
304
306
 
@@ -465,6 +467,36 @@ export class OmpProviderSession implements OmpOpenedSession {
465
467
  this.projector.setBrowserAuthorizationIssuer(issue);
466
468
  }
467
469
 
470
+ async cancelSubagent(handle: string): Promise<boolean> {
471
+ const target = await this.liveSubagent(handle);
472
+ return target ? await target.runtime.cancelSubagent(target.nativeId) : false;
473
+ }
474
+
475
+ async steerSubagent(handle: string, message: string): Promise<void> {
476
+ const target = await this.liveSubagent(handle);
477
+ if (!target) throw new OmpSubagentNotRunningError();
478
+ await target.runtime.steerSubagent(target.nativeId, message);
479
+ }
480
+
481
+ /**
482
+ * The native ID and runtime of a subagent this session's current runtime lists as running, or
483
+ * `null`. OMP repeats the check; this scopes the request to this session and answers exactly.
484
+ */
485
+ private async liveSubagent(
486
+ handle: string,
487
+ ): Promise<{ runtime: OmpRuntimeSession; nativeId: string } | null> {
488
+ if (this.closed || this.runtimeDead) return null;
489
+ const runtime = this.runtime;
490
+ const snapshots = await runtime.getSubagents();
491
+ if (runtime !== this.runtime || this.closed || this.runtimeDead) return null;
492
+ const live = snapshots.find(
493
+ (snapshot) =>
494
+ ompSubagentHandle(this.id, snapshot.id) === handle &&
495
+ isLiveOmpSubagentStatus(snapshot.status),
496
+ );
497
+ return live ? { runtime, nativeId: live.id } : null;
498
+ }
499
+
468
500
  private readonly imageMaterializer = new OmpImageMaterializer();
469
501
  static async open(
470
502
  input: SessionOpenInput,
@@ -2658,6 +2690,9 @@ export class OmpProviderSession implements OmpOpenedSession {
2658
2690
  });
2659
2691
  throw error;
2660
2692
  }
2693
+ if (event.type === "tool_execution_start" && event.toolName === "task") {
2694
+ this.subsessions?.anchorRootTask(event.toolCallId);
2695
+ }
2661
2696
  if (event.type === "tool_execution_end") this.resumeDeferredAgentEnd();
2662
2697
  }
2663
2698
 
@@ -2,6 +2,12 @@ import { createHash } from "node:crypto";
2
2
  import { basename, extname, isAbsolute } from "node:path";
3
3
  import type { ProviderEvent } from "@getpaseo/plugin/server/provider";
4
4
  import { z } from "zod";
5
+ import {
6
+ MAX_OMP_SUBAGENT_STRIP_ENTRIES,
7
+ OMP_SUBAGENT_DESCRIPTION_MAX_LENGTH,
8
+ OMP_SUBAGENT_STRIP_KIND,
9
+ type OmpSubagentStrip,
10
+ } from "../../shared/subagents";
5
11
  import type { OmpRuntime, OmpRuntimeSession } from "./omp-rpc";
6
12
  import type {
7
13
  OmpAgentSessionEvent,
@@ -56,6 +62,7 @@ type ChildState = {
56
62
  sessionClosed: boolean;
57
63
  replayUnavailable?: boolean;
58
64
  seenInSnapshot: boolean;
65
+ runningSince: number;
59
66
  seenAssistantIdentities: BoundedStringSet;
60
67
  projector: OmpTimelineProjector;
61
68
  };
@@ -305,6 +312,19 @@ function terminalStatus(status: string): ChildTerminalStatus | undefined {
305
312
  return;
306
313
  }
307
314
 
315
+ /**
316
+ * OMP derives subagent IDs from model-chosen task names, so clients only see this opaque handle,
317
+ * scoped to the provider session that owns the subagent.
318
+ */
319
+ export function ompSubagentHandle(sessionId: string, nativeId: string): string {
320
+ return createHash("sha256")
321
+ .update(sessionId)
322
+ .update("\0")
323
+ .update(nativeId)
324
+ .digest("base64url")
325
+ .slice(0, 32);
326
+ }
327
+
308
328
  function bufferedNativeId(event: OmpSubagentEvent): string {
309
329
  return event.type === "subagent_progress" ? event.payload.progress.id : event.payload.id;
310
330
  }
@@ -314,6 +334,10 @@ export class OmpSubsessionProjector {
314
334
  private readonly sessionIdByNativeId = new Map<string, string>();
315
335
  private readonly toolOwners = new Map<string, string>();
316
336
  private readonly dispatches = new Map<string, TaskDispatch>();
337
+ /** Last published payload of each non-empty subagent control strip, by timeline item ID. */
338
+ private readonly controlStrips = new Map<string, string>();
339
+ /** Root `task` calls whose tool card the root timeline has published, oldest first. */
340
+ private readonly anchoredRootTasks = new Set<string>();
317
341
  private readonly bufferedEvents: BufferedSubagentEvent[] = [];
318
342
  // null means every child observed during this replay is omitted after tombstone saturation.
319
343
  private omittedBufferedChildren: Set<string> | null = new Set();
@@ -368,7 +392,7 @@ export class OmpSubsessionProjector {
368
392
  if (event.isError) {
369
393
  this.dispatches.delete(event.toolCallId);
370
394
  this.toolOwners.delete(event.toolCallId);
371
- this.onActivityChange();
395
+ this.activityChanged();
372
396
  return;
373
397
  }
374
398
  dispatch.acknowledged = true;
@@ -462,6 +486,82 @@ export class OmpSubsessionProjector {
462
486
  this.reconcileSnapshots(await runtime.getSubagents());
463
487
  }
464
488
 
489
+ private activityChanged(): void {
490
+ this.publishControlStrips();
491
+ this.onActivityChange();
492
+ }
493
+
494
+ /**
495
+ * Marks a root `task` call whose tool card is now in the root timeline. Its control strip is
496
+ * published only after this, so the strip lands below the card even when subagent frames
497
+ * arrive while the turn's own events are still buffered.
498
+ */
499
+ anchorRootTask(toolCallId: string): void {
500
+ if (this.anchoredRootTasks.size >= MAX_TASK_DISPATCHES) {
501
+ const oldest = this.anchoredRootTasks.values().next().value;
502
+ if (oldest !== undefined) this.anchoredRootTasks.delete(oldest);
503
+ }
504
+ this.anchoredRootTasks.add(toolCallId);
505
+ this.publishControlStrips();
506
+ }
507
+
508
+ /**
509
+ * Publishes one plugin timeline row per root `task` call, below its tool card, listing the
510
+ * call's running subagents so the client can steer or cancel them in place. A row is emptied
511
+ * rather than removed when its subagents finish.
512
+ */
513
+ private publishControlStrips(): void {
514
+ if (!this.pluginTimelineEnabled || this.closed) return;
515
+ const strips = new Map<string, OmpSubagentStrip["subagents"]>();
516
+ for (const child of this.children.values()) {
517
+ if (child.status !== "running") continue;
518
+ const taskCallId = this.rootTaskCallId(child);
519
+ if (!taskCallId || !this.anchoredRootTasks.has(taskCallId)) continue;
520
+ const id = `omp:subagents:${taskCallId}`;
521
+ const entries = strips.get(id) ?? [];
522
+ if (entries.length >= MAX_OMP_SUBAGENT_STRIP_ENTRIES) continue;
523
+ entries.push({
524
+ id: ompSubagentHandle(this.rootSessionId, child.nativeId),
525
+ agent: child.title,
526
+ description: child.description
527
+ ? this.dataFilter.text(child.description, OMP_SUBAGENT_DESCRIPTION_MAX_LENGTH)
528
+ : null,
529
+ runningSince: child.runningSince,
530
+ });
531
+ strips.set(id, entries);
532
+ }
533
+ for (const id of new Set([...this.controlStrips.keys(), ...strips.keys()])) {
534
+ const data: OmpSubagentStrip = { subagents: strips.get(id) ?? [] };
535
+ const payload = JSON.stringify(data);
536
+ if (this.controlStrips.get(id) === payload) continue;
537
+ if (data.subagents.length > 0) this.controlStrips.set(id, payload);
538
+ else this.controlStrips.delete(id);
539
+ this.emit({
540
+ type: "timeline.item",
541
+ sessionId: this.rootSessionId,
542
+ item: {
543
+ type: "plugin",
544
+ id,
545
+ pluginId: "paseo-omp",
546
+ kind: OMP_SUBAGENT_STRIP_KIND,
547
+ version: 1,
548
+ data,
549
+ },
550
+ });
551
+ }
552
+ }
553
+
554
+ /** The root timeline's `task` call that a subagent descends from, when it is known. */
555
+ private rootTaskCallId(child: ChildState): string | undefined {
556
+ let current = child;
557
+ for (let depth = 0; current.parentSessionId !== this.rootSessionId; depth += 1) {
558
+ const parent = this.children.get(current.parentSessionId);
559
+ if (!parent || depth >= MAX_CHILDREN) return;
560
+ current = parent;
561
+ }
562
+ return current.parentToolCallId;
563
+ }
564
+
465
565
  hasActiveChildren(): boolean {
466
566
  for (const child of this.children.values()) if (child.status === "running") return true;
467
567
  for (const dispatch of this.dispatches.values()) if (dispatch.acknowledged) return true;
@@ -474,7 +574,7 @@ export class OmpSubsessionProjector {
474
574
  }
475
575
  this.dispatches.clear();
476
576
  this.toolOwners.clear();
477
- this.onActivityChange();
577
+ this.activityChanged();
478
578
  }
479
579
 
480
580
  close(): void {
@@ -626,7 +726,7 @@ export class OmpSubsessionProjector {
626
726
  const terminal = terminalStatus(event.payload.status);
627
727
  if (terminal) this.requestTerminal(child, terminal);
628
728
  else if (event.payload.status === "started") this.restartChild(child);
629
- this.onActivityChange();
729
+ this.activityChanged();
630
730
  return;
631
731
  }
632
732
  if (event.type === "subagent_progress") {
@@ -643,7 +743,7 @@ export class OmpSubsessionProjector {
643
743
  const terminal = terminalStatus(event.payload.progress.status);
644
744
  if (terminal) this.requestTerminal(child, terminal);
645
745
  else this.restartChild(child);
646
- this.onActivityChange();
746
+ this.activityChanged();
647
747
  return;
648
748
  }
649
749
  const sessionId = this.sessionIdByNativeId.get(event.payload.id);
@@ -701,6 +801,7 @@ export class OmpSubsessionProjector {
701
801
  ...(ref.sessionFile ? { sessionFile: ref.sessionFile } : {}),
702
802
  ...(ref.parentToolCallId ? { parentToolCallId: ref.parentToolCallId } : {}),
703
803
  status: "running",
804
+ runningSince: Date.now(),
704
805
  sessionClosed: false,
705
806
  seenAssistantIdentities: new BoundedStringSet(MAX_CHILD_MESSAGE_IDENTITIES),
706
807
  seenInSnapshot: false,
@@ -739,6 +840,7 @@ export class OmpSubsessionProjector {
739
840
  child.replayUnavailable = false;
740
841
  if (child.status === "running") return;
741
842
  child.status = "running";
843
+ child.runningSince = Date.now();
742
844
  child.terminalRequested = undefined;
743
845
  child.seenInSnapshot = false;
744
846
  child.turnSequence += 1;
@@ -784,7 +886,7 @@ export class OmpSubsessionProjector {
784
886
  if (parent?.terminalRequested && !this.hasDirectActivity(parent.sessionId)) {
785
887
  this.finishChild(parent, parent.terminalRequested);
786
888
  }
787
- this.onActivityChange();
889
+ this.activityChanged();
788
890
  }
789
891
  private failReplayChild(child: ChildState): void {
790
892
  child.replayUnavailable = true;
@@ -812,7 +914,7 @@ export class OmpSubsessionProjector {
812
914
  if (owner?.terminalRequested && !this.hasDirectActivity(owner.sessionId)) {
813
915
  this.finishChild(owner, owner.terminalRequested);
814
916
  }
815
- this.onActivityChange();
917
+ this.activityChanged();
816
918
  }
817
919
 
818
920
  private resolveParent(parentToolCallId?: string, sessionFile?: string): string {
@@ -864,7 +966,7 @@ export class OmpSubsessionProjector {
864
966
  this.requestTerminal(child, "completed");
865
967
  }
866
968
  }
867
- this.onActivityChange();
969
+ this.activityChanged();
868
970
  }
869
971
 
870
972
  private async replaySnapshots(
@@ -0,0 +1,97 @@
1
+ import type { RpcInput, RpcOutput } from "@getpaseo/plugin";
2
+ import type { cancelOmpSubagent, steerOmpSubagent } from "../shared/subagents";
3
+ import { OmpRpcRequestRejectedError } from "./provider/omp-rpc-transport";
4
+ import { isOmpPublicError, OmpPublicError } from "./provider/security";
5
+
6
+ /** Subagent controls of one live OMP session; history-only sessions never register. */
7
+ export interface OmpSubagentControls {
8
+ /** Resolves `false` when the subagent is no longer running. */
9
+ cancelSubagent(handle: string): Promise<boolean>;
10
+ steerSubagent(handle: string, message: string): Promise<void>;
11
+ }
12
+
13
+ export class OmpSubagentNotRunningError extends OmpPublicError {
14
+ constructor() {
15
+ super("This subagent is no longer running");
16
+ }
17
+ }
18
+
19
+ type SubagentOperation = "cancel" | "steer";
20
+
21
+ const OPERATION_FAILURES: Record<SubagentOperation, string> = {
22
+ cancel: "OMP could not cancel the subagent",
23
+ steer: "OMP did not accept the message. The subagent may have finished or refused it",
24
+ };
25
+
26
+ // A timed-out request is not withdrawn from OMP, so its outcome is unknown rather than failed.
27
+ const OPERATION_TIMEOUTS: Record<SubagentOperation, string> = {
28
+ cancel: "OMP did not confirm the cancellation in time. Check the subagent before retrying",
29
+ steer: "OMP did not confirm delivery in time. The subagent may still receive the message",
30
+ };
31
+
32
+ /**
33
+ * Routes subagent controls to the OMP session that owns a Paseo agent. Keys are the agent ID the
34
+ * plugin's own `agent.session_open` hook injects, so a request can only reach that agent's
35
+ * runtime, and OMP then resolves the subagent only within that runtime's roster.
36
+ */
37
+ export class OmpSubagentControlRegistry {
38
+ private readonly agents = new Map<string, { controls: OmpSubagentControls }>();
39
+
40
+ /** A newer session for the same agent replaces the older one; its removal is then a no-op. */
41
+ register(agentId: string, controls: OmpSubagentControls): () => void {
42
+ const entry = { controls };
43
+ this.agents.set(agentId, entry);
44
+ return () => {
45
+ if (this.agents.get(agentId) === entry) this.agents.delete(agentId);
46
+ };
47
+ }
48
+
49
+ get(agentId: string): OmpSubagentControls | undefined {
50
+ return this.agents.get(agentId)?.controls;
51
+ }
52
+
53
+ clear(): void {
54
+ this.agents.clear();
55
+ }
56
+ }
57
+
58
+ async function withPublicFailure<T>(operation: SubagentOperation, run: () => Promise<T>) {
59
+ try {
60
+ return await run();
61
+ } catch (error) {
62
+ if (isOmpPublicError(error)) throw error;
63
+ if (error instanceof OmpRpcRequestRejectedError && error.code === "unsupported_command") {
64
+ throw new OmpPublicError("Update OMP to 18.4.9 or newer to control subagents from Paseo");
65
+ }
66
+ if (error instanceof Error && error.message === "OMP RPC request timed out") {
67
+ throw new OmpPublicError(OPERATION_TIMEOUTS[operation]);
68
+ }
69
+ // Native OMP error text is never forwarded; it can echo arbitrary subagent output.
70
+ throw new OmpPublicError(OPERATION_FAILURES[operation]);
71
+ }
72
+ }
73
+
74
+ function controlsFor(registry: OmpSubagentControlRegistry, agentId: string): OmpSubagentControls {
75
+ const controls = registry.get(agentId);
76
+ if (!controls) throw new OmpPublicError("This agent has no running OMP Plugin session");
77
+ return controls;
78
+ }
79
+
80
+ export async function resolveCancelOmpSubagent(
81
+ input: RpcInput<typeof cancelOmpSubagent>,
82
+ registry: OmpSubagentControlRegistry,
83
+ ): Promise<RpcOutput<typeof cancelOmpSubagent>> {
84
+ const controls = controlsFor(registry, input.agentId);
85
+ return {
86
+ cancelled: await withPublicFailure("cancel", () => controls.cancelSubagent(input.subagentId)),
87
+ };
88
+ }
89
+
90
+ export async function resolveSteerOmpSubagent(
91
+ input: RpcInput<typeof steerOmpSubagent>,
92
+ registry: OmpSubagentControlRegistry,
93
+ ): Promise<RpcOutput<typeof steerOmpSubagent>> {
94
+ const controls = controlsFor(registry, input.agentId);
95
+ await withPublicFailure("steer", () => controls.steerSubagent(input.subagentId, input.message));
96
+ return { accepted: true };
97
+ }
@@ -0,0 +1,60 @@
1
+ import { defineRpc } from "@getpaseo/plugin";
2
+ import { z } from "zod";
3
+
4
+ export const OMP_SUBAGENT_STEER_MAX_LENGTH = 16_384;
5
+ export const OMP_SUBAGENT_DESCRIPTION_MAX_LENGTH = 512;
6
+
7
+ const AgentIdSchema = z.string().min(1).max(256);
8
+ const SubagentIdSchema = z.string().min(1).max(256);
9
+
10
+ export const OmpSubagentStatusSchema = z.enum([
11
+ "pending",
12
+ "running",
13
+ "started",
14
+ "completed",
15
+ "failed",
16
+ "aborted",
17
+ ]);
18
+ export type OmpSubagentStatus = z.infer<typeof OmpSubagentStatusSchema>;
19
+
20
+ export const cancelOmpSubagent = defineRpc({
21
+ name: "paseo-omp.cancel-subagent",
22
+ input: z.object({ agentId: AgentIdSchema, subagentId: SubagentIdSchema }),
23
+ /** `false` when the subagent had already finished; cancelling is idempotent. */
24
+ output: z.object({ cancelled: z.boolean() }),
25
+ });
26
+
27
+ export const steerOmpSubagent = defineRpc({
28
+ name: "paseo-omp.steer-subagent",
29
+ input: z.object({
30
+ agentId: AgentIdSchema,
31
+ subagentId: SubagentIdSchema,
32
+ message: z.string().trim().min(1).max(OMP_SUBAGENT_STEER_MAX_LENGTH),
33
+ }),
34
+ output: z.object({ accepted: z.literal(true) }),
35
+ });
36
+
37
+ export const OMP_SUBAGENT_STRIP_KIND = "omp-subagents";
38
+ export const MAX_OMP_SUBAGENT_STRIP_ENTRIES = 64;
39
+
40
+ /** The running subagents of one `task` call, rendered below its tool card. */
41
+ export const ompSubagentStripSchema = z.object({
42
+ subagents: z
43
+ .array(
44
+ z.object({
45
+ /** Opaque handle scoped to the agent's current OMP session, never the native OMP ID. */
46
+ id: SubagentIdSchema,
47
+ agent: z.string().min(1).max(256),
48
+ description: z.string().max(OMP_SUBAGENT_DESCRIPTION_MAX_LENGTH).nullable(),
49
+ /** Epoch milliseconds when the plugin last saw the subagent start running. */
50
+ runningSince: z.number().int().nonnegative(),
51
+ }),
52
+ )
53
+ .max(MAX_OMP_SUBAGENT_STRIP_ENTRIES),
54
+ });
55
+ export type OmpSubagentStrip = z.infer<typeof ompSubagentStripSchema>;
56
+
57
+ /** OMP reaches a subagent only while its roster lists it as running or pending. */
58
+ export function isLiveOmpSubagentStatus(status: OmpSubagentStatus): boolean {
59
+ return status === "pending" || status === "running";
60
+ }