@omercnet/paseo-omp 5.1.1 → 5.2.0-next.362.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 +1 -0
- package/README.md +2 -2
- package/client/provider-image.tsx +127 -14
- package/client/provider-launch-settings.tsx +46 -2
- package/client/subagents-state.ts +108 -0
- package/client/subagents-strip.tsx +391 -0
- package/index.client.tsx +9 -0
- package/index.server.ts +25 -0
- package/npm-shrinkwrap.json +9 -9
- package/package.json +6 -9
- package/scripts/prepare-dependencies.mjs +7 -10
- package/server/mcp-browser.ts +3 -1
- package/server/package-version.ts +1 -1
- package/server/provider/connection.ts +43 -8
- package/server/provider/omp-rpc-process.ts +9 -9
- package/server/provider/omp-rpc-protocol.ts +40 -2
- package/server/provider/omp-rpc-transport.ts +5 -2
- package/server/provider/omp-rpc.ts +25 -0
- package/server/provider/registration.ts +18 -0
- package/server/provider/session-compaction.ts +29 -25
- package/server/provider/session-descriptors.ts +36 -30
- package/server/provider/session-events.ts +1 -0
- package/server/provider/session-history.ts +2 -0
- package/server/provider/session-limit.ts +32 -0
- package/server/provider/session-terminal.ts +52 -1
- package/server/provider/session.ts +255 -67
- package/server/provider/subsessions.ts +109 -7
- package/server/provider/timeline-projector.ts +41 -26
- package/server/subagents.ts +97 -0
- package/shared/provider-launch-settings.ts +4 -0
- package/shared/subagents.ts +60 -0
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. |
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { PluginTimelineItemProps } from "@getpaseo/plugin/client";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { Modal, ScrollView } from "@getpaseo/plugin/client/ui";
|
|
3
|
+
import { useEffect, useMemo, useState } from "react";
|
|
4
|
+
import { Image, Pressable, Text, View } from "react-native";
|
|
4
5
|
import { type OmpImageTimelineData, visibleOmpImageText } from "../shared/provider-image";
|
|
5
6
|
|
|
6
7
|
export function OmpImageTimeline({ item, theme }: PluginTimelineItemProps<OmpImageTimelineData>) {
|
|
@@ -31,6 +32,8 @@ export function OmpImageTimeline({ item, theme }: PluginTimelineItemProps<OmpIma
|
|
|
31
32
|
}),
|
|
32
33
|
[theme],
|
|
33
34
|
);
|
|
35
|
+
const [enlargedId, setEnlargedId] = useState<string | null>(null);
|
|
36
|
+
const enlarged = item.data.images.find((image) => image.id === enlargedId);
|
|
34
37
|
const [failedImageIds, setFailedImageIds] = useState<ReadonlySet<string>>(() => new Set());
|
|
35
38
|
const visibleText = useMemo(() => visibleOmpImageText(item.data.text), [item.data.text]);
|
|
36
39
|
return (
|
|
@@ -46,21 +49,131 @@ export function OmpImageTimeline({ item, theme }: PluginTimelineItemProps<OmpIma
|
|
|
46
49
|
</Text>
|
|
47
50
|
</View>
|
|
48
51
|
) : (
|
|
49
|
-
<
|
|
52
|
+
<Pressable
|
|
50
53
|
key={image.id}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
54
|
+
accessibilityRole="button"
|
|
55
|
+
accessibilityLabel={`Enlarge ${item.data.label} image ${index + 1}`}
|
|
56
|
+
onPress={() => setEnlargedId(image.id)}
|
|
57
|
+
>
|
|
58
|
+
<Image
|
|
59
|
+
source={{ uri: `data:${image.mimeType};base64,${image.data}` }}
|
|
60
|
+
resizeMode="contain"
|
|
61
|
+
accessibilityLabel={`${item.data.label} image ${index + 1}`}
|
|
62
|
+
onError={() =>
|
|
63
|
+
setFailedImageIds((current) => {
|
|
64
|
+
if (current.has(image.id)) return current;
|
|
65
|
+
return new Set([...current, image.id]);
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
style={styles.image}
|
|
69
|
+
/>
|
|
70
|
+
</Pressable>
|
|
62
71
|
),
|
|
63
72
|
)}
|
|
73
|
+
<Modal
|
|
74
|
+
title={item.data.label}
|
|
75
|
+
open={enlarged !== undefined}
|
|
76
|
+
onOpenChange={(open) => {
|
|
77
|
+
if (!open) setEnlargedId(null);
|
|
78
|
+
}}
|
|
79
|
+
>
|
|
80
|
+
<Modal.Content scrollable={false}>
|
|
81
|
+
{enlarged ? (
|
|
82
|
+
<EnlargedImage
|
|
83
|
+
key={enlarged.id}
|
|
84
|
+
failed={failedImageIds.has(enlarged.id)}
|
|
85
|
+
uri={`data:${enlarged.mimeType};base64,${enlarged.data}`}
|
|
86
|
+
label={item.data.label}
|
|
87
|
+
theme={theme}
|
|
88
|
+
/>
|
|
89
|
+
) : null}
|
|
90
|
+
</Modal.Content>
|
|
91
|
+
</Modal>
|
|
92
|
+
</View>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Fits the image to the dialog; a tap shows it at its real pixel size, panned by scrolling. */
|
|
97
|
+
function EnlargedImage({
|
|
98
|
+
uri,
|
|
99
|
+
label,
|
|
100
|
+
failed,
|
|
101
|
+
theme,
|
|
102
|
+
}: {
|
|
103
|
+
uri: string;
|
|
104
|
+
failed: boolean;
|
|
105
|
+
label: string;
|
|
106
|
+
theme: PluginTimelineItemProps<OmpImageTimelineData>["theme"];
|
|
107
|
+
}) {
|
|
108
|
+
const [natural, setNatural] = useState<{ width: number; height: number } | null>(null);
|
|
109
|
+
const [actualSize, setActualSize] = useState(false);
|
|
110
|
+
const [renderFailed, setRenderFailed] = useState(false);
|
|
111
|
+
const [sizeUnavailable, setSizeUnavailable] = useState(false);
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
let current = true;
|
|
114
|
+
Image.getSize(
|
|
115
|
+
uri,
|
|
116
|
+
(width, height) => current && setNatural({ width, height }),
|
|
117
|
+
() => current && setSizeUnavailable(true),
|
|
118
|
+
);
|
|
119
|
+
return () => {
|
|
120
|
+
current = false;
|
|
121
|
+
};
|
|
122
|
+
}, [uri]);
|
|
123
|
+
const canZoom = natural !== null;
|
|
124
|
+
// The modal body is bounded (`scrollable={false}`), so the image and its scroller share its height
|
|
125
|
+
// with the caption instead of guessing from the window.
|
|
126
|
+
const fill = { flex: 1, minHeight: 0 };
|
|
127
|
+
const mutedText = {
|
|
128
|
+
color: theme.colors.foregroundMuted,
|
|
129
|
+
fontSize: 12,
|
|
130
|
+
textAlign: "center" as const,
|
|
131
|
+
};
|
|
132
|
+
if (failed || renderFailed) {
|
|
133
|
+
return <Text style={mutedText}>This image could not be rendered on this Paseo client.</Text>;
|
|
134
|
+
}
|
|
135
|
+
return (
|
|
136
|
+
<View style={[fill, { gap: 8 }]}>
|
|
137
|
+
<Pressable
|
|
138
|
+
accessibilityRole="button"
|
|
139
|
+
accessibilityLabel={
|
|
140
|
+
actualSize ? `Fit ${label} image to window` : `Show ${label} image at actual size`
|
|
141
|
+
}
|
|
142
|
+
disabled={!canZoom}
|
|
143
|
+
onPress={() => setActualSize((value) => !value)}
|
|
144
|
+
style={fill}
|
|
145
|
+
>
|
|
146
|
+
{actualSize && natural ? (
|
|
147
|
+
<ScrollView style={fill}>
|
|
148
|
+
<ScrollView horizontal>
|
|
149
|
+
<Image
|
|
150
|
+
source={{ uri }}
|
|
151
|
+
accessibilityLabel={`${label} enlarged image`}
|
|
152
|
+
onError={() => setRenderFailed(true)}
|
|
153
|
+
style={{ width: natural.width, height: natural.height }}
|
|
154
|
+
/>
|
|
155
|
+
</ScrollView>
|
|
156
|
+
</ScrollView>
|
|
157
|
+
) : (
|
|
158
|
+
<Image
|
|
159
|
+
source={{ uri }}
|
|
160
|
+
resizeMode="contain"
|
|
161
|
+
accessibilityLabel={`${label} enlarged image`}
|
|
162
|
+
onError={() => setRenderFailed(true)}
|
|
163
|
+
style={[fill, { width: "100%" }]}
|
|
164
|
+
/>
|
|
165
|
+
)}
|
|
166
|
+
</Pressable>
|
|
167
|
+
{canZoom ? (
|
|
168
|
+
<Text style={mutedText}>
|
|
169
|
+
{actualSize
|
|
170
|
+
? `${natural.width} × ${natural.height}px · tap to fit`
|
|
171
|
+
: `Tap the image to see it at ${natural.width} × ${natural.height}px`}
|
|
172
|
+
</Text>
|
|
173
|
+
) : null}
|
|
174
|
+
{sizeUnavailable ? (
|
|
175
|
+
<Text style={mutedText}>The actual size of this image is not available.</Text>
|
|
176
|
+
) : null}
|
|
64
177
|
</View>
|
|
65
178
|
);
|
|
66
179
|
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
9
9
|
import { Text, View } from "react-native";
|
|
10
10
|
import {
|
|
11
|
+
MAX_SESSIONS_UPPER_BOUND,
|
|
11
12
|
providerLaunchSettings,
|
|
12
13
|
providerLaunchSettingsSchema,
|
|
13
14
|
} from "../shared/provider-launch-settings";
|
|
@@ -23,6 +24,11 @@ function ProviderLaunchSettingsControls({ settings }: { settings: ReadySettings
|
|
|
23
24
|
useEffect(() => {
|
|
24
25
|
setRawNames(savedNames);
|
|
25
26
|
}, [savedNames]);
|
|
27
|
+
const savedMaxSessions = String(settings.values.maxSessions);
|
|
28
|
+
const [rawMaxSessions, setRawMaxSessions] = useState(() => savedMaxSessions);
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
setRawMaxSessions(savedMaxSessions);
|
|
31
|
+
}, [savedMaxSessions]);
|
|
26
32
|
const inheritEnv = [
|
|
27
33
|
...new Set(
|
|
28
34
|
rawNames
|
|
@@ -31,7 +37,7 @@ function ProviderLaunchSettingsControls({ settings }: { settings: ReadySettings
|
|
|
31
37
|
.filter(Boolean),
|
|
32
38
|
),
|
|
33
39
|
];
|
|
34
|
-
const next = providerLaunchSettingsSchema.safeParse({ inheritEnv });
|
|
40
|
+
const next = providerLaunchSettingsSchema.safeParse({ ...settings.values, inheritEnv });
|
|
35
41
|
const error = next.success
|
|
36
42
|
? null
|
|
37
43
|
: (next.error.issues[0]?.message ?? "Enter valid variable names.");
|
|
@@ -39,6 +45,14 @@ function ProviderLaunchSettingsControls({ settings }: { settings: ReadySettings
|
|
|
39
45
|
if (!next.success) return;
|
|
40
46
|
await settings.save(next.data, settings.revision);
|
|
41
47
|
}, [next, settings]);
|
|
48
|
+
const nextMaxSessions = providerLaunchSettingsSchema.safeParse({
|
|
49
|
+
...settings.values,
|
|
50
|
+
maxSessions: Number(rawMaxSessions.trim()),
|
|
51
|
+
});
|
|
52
|
+
const saveMaxSessions = useCallback(async () => {
|
|
53
|
+
if (!nextMaxSessions.success) return;
|
|
54
|
+
await settings.save(nextMaxSessions.data, settings.revision);
|
|
55
|
+
}, [nextMaxSessions, settings]);
|
|
42
56
|
|
|
43
57
|
return (
|
|
44
58
|
<View style={{ gap: 12 }}>
|
|
@@ -69,7 +83,9 @@ function ProviderLaunchSettingsControls({ settings }: { settings: ReadySettings
|
|
|
69
83
|
actionLabel="Clear"
|
|
70
84
|
disabled={settings.saving || settings.values.inheritEnv.length === 0}
|
|
71
85
|
onPress={async () => {
|
|
72
|
-
if (await settings.save({ inheritEnv: [] }, settings.revision))
|
|
86
|
+
if (await settings.save({ ...settings.values, inheritEnv: [] }, settings.revision)) {
|
|
87
|
+
setRawNames("");
|
|
88
|
+
}
|
|
73
89
|
}}
|
|
74
90
|
/>
|
|
75
91
|
{settings.saveError ? (
|
|
@@ -83,6 +99,34 @@ function ProviderLaunchSettingsControls({ settings }: { settings: ReadySettings
|
|
|
83
99
|
) : null}
|
|
84
100
|
</SettingsCard>
|
|
85
101
|
</SettingsSection>
|
|
102
|
+
<SettingsSection
|
|
103
|
+
title="Session limit"
|
|
104
|
+
info="Applies to all OMP Plugin agents on this host together, including idle ones."
|
|
105
|
+
>
|
|
106
|
+
<SettingsCard>
|
|
107
|
+
<SettingsInput
|
|
108
|
+
key={savedMaxSessions}
|
|
109
|
+
label="Open sessions"
|
|
110
|
+
hint="New OMP Plugin agents are refused while this many sessions are open. Archive idle agents to free sessions."
|
|
111
|
+
initialValue={savedMaxSessions}
|
|
112
|
+
onChangeText={setRawMaxSessions}
|
|
113
|
+
error={
|
|
114
|
+
nextMaxSessions.success
|
|
115
|
+
? undefined
|
|
116
|
+
: `Enter a whole number from 1 to ${MAX_SESSIONS_UPPER_BOUND}.`
|
|
117
|
+
}
|
|
118
|
+
disabled={settings.saving}
|
|
119
|
+
/>
|
|
120
|
+
<SettingsAction
|
|
121
|
+
label="Save session limit"
|
|
122
|
+
actionLabel="Save"
|
|
123
|
+
disabled={
|
|
124
|
+
settings.saving || rawMaxSessions === savedMaxSessions || !nextMaxSessions.success
|
|
125
|
+
}
|
|
126
|
+
onPress={() => void saveMaxSessions()}
|
|
127
|
+
/>
|
|
128
|
+
</SettingsCard>
|
|
129
|
+
</SettingsSection>
|
|
86
130
|
</View>
|
|
87
131
|
);
|
|
88
132
|
}
|
|
@@ -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
|
+
}
|