@omercnet/paseo-tell-agent 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/client/agents.ts +69 -0
- package/client/message-agent.tsx +363 -0
- package/client/messaging.ts +63 -0
- package/client/tell-command.ts +33 -0
- package/index.client.tsx +6 -0
- package/package.json +46 -0
- package/paseo-plugin.json +4 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Omer Cohen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/client/agents.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { PaseoAgent, PaseoAgentListResult, PaseoApi } from "@getpaseo/client";
|
|
2
|
+
|
|
3
|
+
export type AgentEntry = PaseoAgentListResult["entries"][number];
|
|
4
|
+
type AgentSnapshot = PaseoAgent;
|
|
5
|
+
|
|
6
|
+
export const AGENT_PAGE_LIMIT = 200;
|
|
7
|
+
export const MAX_AGENT_PAGES = 10;
|
|
8
|
+
|
|
9
|
+
export type AgentDirectoryPage = {
|
|
10
|
+
entries: AgentEntry[];
|
|
11
|
+
truncated: boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export async function loadAgents(paseo: PaseoApi): Promise<AgentDirectoryPage> {
|
|
15
|
+
const entries: AgentEntry[] = [];
|
|
16
|
+
let cursor: string | undefined;
|
|
17
|
+
for (let page = 0; page < MAX_AGENT_PAGES; page += 1) {
|
|
18
|
+
const result = await paseo.agents.list({
|
|
19
|
+
sort: [{ key: "updated_at", direction: "desc" }],
|
|
20
|
+
page: { limit: AGENT_PAGE_LIMIT, ...(cursor ? { cursor } : {}) },
|
|
21
|
+
});
|
|
22
|
+
entries.push(...result.entries);
|
|
23
|
+
cursor = result.pageInfo.hasMore ? (result.pageInfo.nextCursor ?? undefined) : undefined;
|
|
24
|
+
if (!cursor) return { entries, truncated: false };
|
|
25
|
+
}
|
|
26
|
+
return { entries, truncated: true };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function title(entry: AgentEntry): string {
|
|
30
|
+
const explicit = entry.agent.title?.trim();
|
|
31
|
+
return explicit && explicit.length > 0 ? explicit : entry.agent.id.slice(0, 7);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function placement(entry: AgentEntry): string {
|
|
35
|
+
const { project } = entry;
|
|
36
|
+
const workspace = project.workspaceName?.trim();
|
|
37
|
+
const scope =
|
|
38
|
+
workspace && workspace !== project.projectName
|
|
39
|
+
? `${project.projectName} / ${workspace}`
|
|
40
|
+
: project.projectName;
|
|
41
|
+
const branch = project.checkout.isGit ? project.checkout.currentBranch : null;
|
|
42
|
+
return branch ? `${scope} · ${branch}` : scope;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function stateLabel(agent: AgentSnapshot): string {
|
|
46
|
+
const permissionCount = agent.pendingPermissions.length;
|
|
47
|
+
if (permissionCount > 0) {
|
|
48
|
+
return permissionCount === 1 ? "permission" : `${permissionCount} permissions`;
|
|
49
|
+
}
|
|
50
|
+
if (agent.status === "error") return "error";
|
|
51
|
+
if (agent.requiresAttention) return agent.attentionReason ?? "attention";
|
|
52
|
+
return agent.status === "initializing" ? "starting" : agent.status;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function matches(entry: AgentEntry, needle: string): boolean {
|
|
56
|
+
if (needle.length === 0) return true;
|
|
57
|
+
return [
|
|
58
|
+
entry.agent.title ?? "",
|
|
59
|
+
entry.agent.id,
|
|
60
|
+
entry.agent.provider,
|
|
61
|
+
entry.agent.model ?? "",
|
|
62
|
+
entry.agent.cwd,
|
|
63
|
+
entry.project.projectName,
|
|
64
|
+
entry.project.workspaceName ?? "",
|
|
65
|
+
]
|
|
66
|
+
.join(" ")
|
|
67
|
+
.toLowerCase()
|
|
68
|
+
.includes(needle);
|
|
69
|
+
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type PluginAgentPanelProps,
|
|
3
|
+
type PluginButtonContentProps,
|
|
4
|
+
type PluginClientContext,
|
|
5
|
+
usePaseo,
|
|
6
|
+
} from "@getpaseo/plugin/client";
|
|
7
|
+
import { Icon, useToast } from "@getpaseo/plugin/client/react-native";
|
|
8
|
+
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
9
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
10
|
+
import { Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
|
11
|
+
import {
|
|
12
|
+
AGENT_PAGE_LIMIT,
|
|
13
|
+
type AgentEntry,
|
|
14
|
+
loadAgents,
|
|
15
|
+
MAX_AGENT_PAGES,
|
|
16
|
+
placement,
|
|
17
|
+
stateLabel,
|
|
18
|
+
title,
|
|
19
|
+
} from "./agents";
|
|
20
|
+
import { formatTellInstruction, messageTargets } from "./messaging";
|
|
21
|
+
import { handleTellCommand } from "./tell-command";
|
|
22
|
+
|
|
23
|
+
const VISIBLE_TARGETS = 8;
|
|
24
|
+
|
|
25
|
+
type MessageAgentFormProps = {
|
|
26
|
+
sourceAgentId: string;
|
|
27
|
+
theme: PluginButtonContentProps["theme"];
|
|
28
|
+
host: PluginButtonContentProps["host"];
|
|
29
|
+
close?: () => void;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function MessageAgentForm({ sourceAgentId, theme, host, close }: MessageAgentFormProps) {
|
|
33
|
+
const paseo = usePaseo();
|
|
34
|
+
const toast = useToast();
|
|
35
|
+
const [query, setQuery] = useState("");
|
|
36
|
+
const [targetId, setTargetId] = useState<string | null>(null);
|
|
37
|
+
const [message, setMessage] = useState("");
|
|
38
|
+
const messageInputRef = useRef<TextInput>(null);
|
|
39
|
+
const directory = useQuery({
|
|
40
|
+
queryKey: ["tell-agent", "message-targets", host.id],
|
|
41
|
+
queryFn: () => loadAgents(paseo),
|
|
42
|
+
});
|
|
43
|
+
const entries = directory.data?.entries ?? [];
|
|
44
|
+
const target = targetId ? entries.find((entry) => entry.agent.id === targetId) : undefined;
|
|
45
|
+
const matchingTargets = useMemo(
|
|
46
|
+
() => messageTargets(entries, sourceAgentId, query),
|
|
47
|
+
[entries, query, sourceAgentId],
|
|
48
|
+
);
|
|
49
|
+
const visibleTargets = matchingTargets.slice(0, VISIBLE_TARGETS);
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (!targetId) return;
|
|
52
|
+
const focusTimer = setTimeout(() => messageInputRef.current?.focus(), 0);
|
|
53
|
+
return () => clearTimeout(focusTimer);
|
|
54
|
+
}, [targetId]);
|
|
55
|
+
const send = useMutation({
|
|
56
|
+
mutationFn: async ({ entry, text }: { entry: AgentEntry; text: string }) => {
|
|
57
|
+
await paseo.agents.ref(sourceAgentId).send(formatTellInstruction(entry.agent.id, text));
|
|
58
|
+
return entry;
|
|
59
|
+
},
|
|
60
|
+
onSuccess(entry) {
|
|
61
|
+
toast.show(`Asked current agent to tell ${title(entry)}`, { variant: "success" });
|
|
62
|
+
if (close) close();
|
|
63
|
+
else {
|
|
64
|
+
setTargetId(null);
|
|
65
|
+
setMessage("");
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
const styles = useMemo(
|
|
70
|
+
() => ({
|
|
71
|
+
body: { gap: 12 },
|
|
72
|
+
heading: { color: theme.colors.foreground, fontSize: 18, fontWeight: "600" as const },
|
|
73
|
+
detail: { color: theme.colors.foregroundMuted, fontSize: 13, lineHeight: 18 },
|
|
74
|
+
error: { color: theme.colors.statusDanger, fontSize: 13, lineHeight: 18 },
|
|
75
|
+
warning: { color: theme.colors.statusWarning, fontSize: 13, lineHeight: 18 },
|
|
76
|
+
input: {
|
|
77
|
+
minHeight: 38,
|
|
78
|
+
borderWidth: 1,
|
|
79
|
+
borderColor: theme.colors.border,
|
|
80
|
+
borderRadius: 8,
|
|
81
|
+
paddingHorizontal: 10,
|
|
82
|
+
paddingVertical: 8,
|
|
83
|
+
color: theme.colors.foreground,
|
|
84
|
+
backgroundColor: theme.colors.surface1,
|
|
85
|
+
},
|
|
86
|
+
message: {
|
|
87
|
+
minHeight: 92,
|
|
88
|
+
borderWidth: 1,
|
|
89
|
+
borderColor: theme.colors.border,
|
|
90
|
+
borderRadius: 8,
|
|
91
|
+
paddingHorizontal: 10,
|
|
92
|
+
paddingVertical: 8,
|
|
93
|
+
color: theme.colors.foreground,
|
|
94
|
+
backgroundColor: theme.colors.surface1,
|
|
95
|
+
textAlignVertical: "top" as const,
|
|
96
|
+
},
|
|
97
|
+
results: { gap: 6 },
|
|
98
|
+
row: {
|
|
99
|
+
minHeight: 48,
|
|
100
|
+
flexDirection: "row" as const,
|
|
101
|
+
alignItems: "center" as const,
|
|
102
|
+
gap: 9,
|
|
103
|
+
paddingHorizontal: 10,
|
|
104
|
+
paddingVertical: 8,
|
|
105
|
+
borderWidth: 1,
|
|
106
|
+
borderColor: theme.colors.border,
|
|
107
|
+
borderRadius: 8,
|
|
108
|
+
backgroundColor: theme.colors.surface1,
|
|
109
|
+
},
|
|
110
|
+
rowSelected: {
|
|
111
|
+
borderColor: theme.colors.accent,
|
|
112
|
+
backgroundColor: theme.colors.surface2,
|
|
113
|
+
},
|
|
114
|
+
rowText: { flex: 1, minWidth: 0, gap: 2 },
|
|
115
|
+
rowTitle: { color: theme.colors.foreground, fontWeight: "600" as const, fontSize: 13 },
|
|
116
|
+
rowDetail: { color: theme.colors.foregroundMuted, fontSize: 12 },
|
|
117
|
+
state: { color: theme.colors.foregroundMuted, fontSize: 11 },
|
|
118
|
+
actions: { flexDirection: "row" as const, justifyContent: "flex-end" as const, gap: 8 },
|
|
119
|
+
button: {
|
|
120
|
+
minHeight: 36,
|
|
121
|
+
justifyContent: "center" as const,
|
|
122
|
+
paddingHorizontal: 12,
|
|
123
|
+
borderRadius: 8,
|
|
124
|
+
borderWidth: 1,
|
|
125
|
+
borderColor: theme.colors.border,
|
|
126
|
+
backgroundColor: theme.colors.surface1,
|
|
127
|
+
},
|
|
128
|
+
primary: { borderColor: theme.colors.accent, backgroundColor: theme.colors.accent },
|
|
129
|
+
buttonText: { color: theme.colors.foreground, fontWeight: "600" as const },
|
|
130
|
+
primaryText: { color: theme.colors.accentForeground, fontWeight: "600" as const },
|
|
131
|
+
disabled: { opacity: 0.4 },
|
|
132
|
+
}),
|
|
133
|
+
[theme],
|
|
134
|
+
);
|
|
135
|
+
const canSend = Boolean(target && message.trim()) && !send.isPending;
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<View style={styles.body}>
|
|
139
|
+
<Text style={styles.heading}>Tell another agent</Text>
|
|
140
|
+
<Text style={styles.detail}>
|
|
141
|
+
Search active sessions on {host.label}. Your current agent receives the instruction and can
|
|
142
|
+
add context before contacting the selected agent.
|
|
143
|
+
</Text>
|
|
144
|
+
<TextInput
|
|
145
|
+
accessibilityLabel="Search message targets"
|
|
146
|
+
autoCapitalize="none"
|
|
147
|
+
autoCorrect={false}
|
|
148
|
+
onChangeText={setQuery}
|
|
149
|
+
placeholder="Search agent, workspace, project, or model"
|
|
150
|
+
placeholderTextColor={theme.colors.foregroundMuted}
|
|
151
|
+
value={query}
|
|
152
|
+
style={styles.input}
|
|
153
|
+
/>
|
|
154
|
+
{directory.isPending ? <Text style={styles.detail}>Loading agents…</Text> : null}
|
|
155
|
+
{directory.error ? (
|
|
156
|
+
<Text accessibilityRole="alert" style={styles.error}>
|
|
157
|
+
{directory.error.message}
|
|
158
|
+
</Text>
|
|
159
|
+
) : null}
|
|
160
|
+
{!directory.isPending && !directory.error ? (
|
|
161
|
+
<View style={styles.results}>
|
|
162
|
+
{visibleTargets.map((entry) => {
|
|
163
|
+
const selected = entry.agent.id === targetId;
|
|
164
|
+
return (
|
|
165
|
+
<Pressable
|
|
166
|
+
key={entry.agent.id}
|
|
167
|
+
accessibilityRole="button"
|
|
168
|
+
accessibilityLabel={`Select ${title(entry)} in ${placement(entry)}`}
|
|
169
|
+
accessibilityState={{ selected }}
|
|
170
|
+
onPress={() => setTargetId(entry.agent.id)}
|
|
171
|
+
style={({ pressed }) => [
|
|
172
|
+
styles.row,
|
|
173
|
+
selected && styles.rowSelected,
|
|
174
|
+
pressed && styles.disabled,
|
|
175
|
+
]}
|
|
176
|
+
>
|
|
177
|
+
<Icon
|
|
178
|
+
name={selected ? "CircleCheck" : "MessageSquareMore"}
|
|
179
|
+
size={16}
|
|
180
|
+
color={selected ? theme.colors.accent : theme.colors.foregroundMuted}
|
|
181
|
+
/>
|
|
182
|
+
<View style={styles.rowText}>
|
|
183
|
+
<Text numberOfLines={1} style={styles.rowTitle}>
|
|
184
|
+
{title(entry)}
|
|
185
|
+
</Text>
|
|
186
|
+
<Text numberOfLines={1} style={styles.rowDetail}>
|
|
187
|
+
{placement(entry)}
|
|
188
|
+
</Text>
|
|
189
|
+
</View>
|
|
190
|
+
<Text style={styles.state}>{stateLabel(entry.agent)}</Text>
|
|
191
|
+
</Pressable>
|
|
192
|
+
);
|
|
193
|
+
})}
|
|
194
|
+
{matchingTargets.length === 0 ? (
|
|
195
|
+
<Text style={styles.detail}>No active agents match this search.</Text>
|
|
196
|
+
) : null}
|
|
197
|
+
{matchingTargets.length > VISIBLE_TARGETS ? (
|
|
198
|
+
<Text style={styles.detail}>
|
|
199
|
+
Showing {VISIBLE_TARGETS} of {matchingTargets.length}. Refine the search to narrow it.
|
|
200
|
+
</Text>
|
|
201
|
+
) : null}
|
|
202
|
+
{directory.data?.truncated ? (
|
|
203
|
+
<Text style={styles.warning}>
|
|
204
|
+
Only the first {AGENT_PAGE_LIMIT * MAX_AGENT_PAGES} agents were searched.
|
|
205
|
+
</Text>
|
|
206
|
+
) : null}
|
|
207
|
+
</View>
|
|
208
|
+
) : null}
|
|
209
|
+
{target ? (
|
|
210
|
+
<TextInput
|
|
211
|
+
ref={messageInputRef}
|
|
212
|
+
accessibilityLabel={`Instruction for ${title(target)}`}
|
|
213
|
+
multiline
|
|
214
|
+
onChangeText={setMessage}
|
|
215
|
+
placeholder="What should your current agent tell this agent?"
|
|
216
|
+
placeholderTextColor={theme.colors.foregroundMuted}
|
|
217
|
+
value={message}
|
|
218
|
+
style={styles.message}
|
|
219
|
+
/>
|
|
220
|
+
) : null}
|
|
221
|
+
{send.error ? (
|
|
222
|
+
<Text accessibilityRole="alert" style={styles.error}>
|
|
223
|
+
{send.error.message}
|
|
224
|
+
</Text>
|
|
225
|
+
) : null}
|
|
226
|
+
<View style={styles.actions}>
|
|
227
|
+
<Pressable
|
|
228
|
+
accessibilityRole="button"
|
|
229
|
+
disabled={send.isPending}
|
|
230
|
+
onPress={() => {
|
|
231
|
+
if (close) close();
|
|
232
|
+
else {
|
|
233
|
+
setTargetId(null);
|
|
234
|
+
setMessage("");
|
|
235
|
+
}
|
|
236
|
+
}}
|
|
237
|
+
style={({ pressed }) => [styles.button, pressed && styles.disabled]}
|
|
238
|
+
>
|
|
239
|
+
<Text style={styles.buttonText}>{close ? "Cancel" : "Clear"}</Text>
|
|
240
|
+
</Pressable>
|
|
241
|
+
<Pressable
|
|
242
|
+
accessibilityRole="button"
|
|
243
|
+
accessibilityLabel={
|
|
244
|
+
target ? `Ask current agent to tell ${title(target)}` : "Select a target agent"
|
|
245
|
+
}
|
|
246
|
+
disabled={!canSend}
|
|
247
|
+
onPress={() => {
|
|
248
|
+
if (target && message.trim()) send.mutate({ entry: target, text: message });
|
|
249
|
+
}}
|
|
250
|
+
style={({ pressed }) => [
|
|
251
|
+
styles.button,
|
|
252
|
+
styles.primary,
|
|
253
|
+
(!canSend || pressed) && styles.disabled,
|
|
254
|
+
]}
|
|
255
|
+
>
|
|
256
|
+
<Text style={styles.primaryText}>
|
|
257
|
+
{send.isPending ? "Sending…" : "Ask current agent"}
|
|
258
|
+
</Text>
|
|
259
|
+
</Pressable>
|
|
260
|
+
</View>
|
|
261
|
+
</View>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function MessageAgentPopover(props: PluginButtonContentProps) {
|
|
266
|
+
if (props.context !== "agent") return null;
|
|
267
|
+
return <MessageAgentForm {...props} sourceAgentId={props.agentId} />;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function TellAgentPanel({ theme, host, layout, agentId }: PluginAgentPanelProps) {
|
|
271
|
+
const contentStyle = useMemo(
|
|
272
|
+
() => ({
|
|
273
|
+
flexGrow: 1,
|
|
274
|
+
padding: layout.compact ? 14 : 20,
|
|
275
|
+
backgroundColor: theme.colors.surface0,
|
|
276
|
+
}),
|
|
277
|
+
[layout.compact, theme.colors.surface0],
|
|
278
|
+
);
|
|
279
|
+
return (
|
|
280
|
+
<ScrollView contentContainerStyle={contentStyle} keyboardShouldPersistTaps="handled">
|
|
281
|
+
<MessageAgentForm sourceAgentId={agentId} theme={theme} host={host} />
|
|
282
|
+
</ScrollView>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function contributeAgentMessaging(client: PluginClientContext) {
|
|
287
|
+
const removePanel = client.addWorkspacePanel({
|
|
288
|
+
id: "tell-agent",
|
|
289
|
+
title: "Tell agent",
|
|
290
|
+
icon: "MessagesSquare",
|
|
291
|
+
context: "agent",
|
|
292
|
+
locations: ["workspace", "explorer"],
|
|
293
|
+
Component: TellAgentPanel,
|
|
294
|
+
});
|
|
295
|
+
const pills = new Map<string, { workspaceId: string; remove(): void }>();
|
|
296
|
+
let stopped = false;
|
|
297
|
+
|
|
298
|
+
function removePill(agentId: string) {
|
|
299
|
+
pills.get(agentId)?.remove();
|
|
300
|
+
pills.delete(agentId);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function syncAgent(agent: {
|
|
304
|
+
id: string;
|
|
305
|
+
workspaceId?: string | null;
|
|
306
|
+
status: string;
|
|
307
|
+
archivedAt?: string | null;
|
|
308
|
+
}) {
|
|
309
|
+
if (!agent.workspaceId || agent.archivedAt || agent.status === "closed") {
|
|
310
|
+
removePill(agent.id);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const next = { id: agent.id, workspaceId: agent.workspaceId };
|
|
314
|
+
const current = pills.get(agent.id);
|
|
315
|
+
if (current?.workspaceId === next.workspaceId) return;
|
|
316
|
+
removePill(agent.id);
|
|
317
|
+
const pill = client.addComposerPill({
|
|
318
|
+
id: "message-agent",
|
|
319
|
+
workspaceId: next.workspaceId,
|
|
320
|
+
agentId: next.id,
|
|
321
|
+
button: {
|
|
322
|
+
title: "Message another agent",
|
|
323
|
+
label: "Tell agent",
|
|
324
|
+
icon: "MessagesSquare",
|
|
325
|
+
behavior: { kind: "popover", Content: MessageAgentPopover },
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
pills.set(agent.id, { workspaceId: next.workspaceId, remove: pill.remove });
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const unsubscribe = client.paseo.agents.subscribe((update) => {
|
|
332
|
+
if (update.kind === "remove") {
|
|
333
|
+
removePill(update.agentId);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
syncAgent(update.agent);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
void loadAgents(client.paseo)
|
|
340
|
+
.then(({ entries }) => {
|
|
341
|
+
if (stopped) return;
|
|
342
|
+
for (const { agent } of entries) syncAgent(agent);
|
|
343
|
+
})
|
|
344
|
+
.catch(() => undefined);
|
|
345
|
+
|
|
346
|
+
const removeTellCommand = client.addSlashCommand({
|
|
347
|
+
name: "tell",
|
|
348
|
+
description: "Open the agent picker or message another session",
|
|
349
|
+
argumentHint: "[agent or workspace :: message]",
|
|
350
|
+
context: "agent",
|
|
351
|
+
onSubmit: handleTellCommand,
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
return () => {
|
|
355
|
+
if (stopped) return;
|
|
356
|
+
stopped = true;
|
|
357
|
+
removeTellCommand();
|
|
358
|
+
removePanel();
|
|
359
|
+
unsubscribe();
|
|
360
|
+
for (const pill of pills.values()) pill.remove();
|
|
361
|
+
pills.clear();
|
|
362
|
+
};
|
|
363
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { type AgentEntry, matches, title } from "./agents";
|
|
2
|
+
|
|
3
|
+
export type TellArguments = { target: string; message: string };
|
|
4
|
+
|
|
5
|
+
export type TargetResolution =
|
|
6
|
+
| { kind: "match"; entry: AgentEntry }
|
|
7
|
+
| { kind: "none" }
|
|
8
|
+
| { kind: "ambiguous"; entries: AgentEntry[] };
|
|
9
|
+
|
|
10
|
+
export function isMessageable(entry: AgentEntry, sourceAgentId: string): boolean {
|
|
11
|
+
const agent = entry.agent;
|
|
12
|
+
return agent.id !== sourceAgentId && !agent.archivedAt && agent.status !== "closed";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function messageTargets(
|
|
16
|
+
entries: readonly AgentEntry[],
|
|
17
|
+
sourceAgentId: string,
|
|
18
|
+
query: string,
|
|
19
|
+
): AgentEntry[] {
|
|
20
|
+
const needle = query.trim().replace(/^@/, "").toLowerCase();
|
|
21
|
+
return entries
|
|
22
|
+
.filter((entry) => isMessageable(entry, sourceAgentId) && matches(entry, needle))
|
|
23
|
+
.sort((left, right) => {
|
|
24
|
+
const leftTitle = title(left).toLowerCase();
|
|
25
|
+
const rightTitle = title(right).toLowerCase();
|
|
26
|
+
const leftExact = left.agent.id.toLowerCase() === needle || leftTitle === needle;
|
|
27
|
+
const rightExact = right.agent.id.toLowerCase() === needle || rightTitle === needle;
|
|
28
|
+
if (leftExact !== rightExact) return leftExact ? -1 : 1;
|
|
29
|
+
return Date.parse(right.agent.updatedAt) - Date.parse(left.agent.updatedAt);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function resolveMessageTarget(
|
|
34
|
+
entries: readonly AgentEntry[],
|
|
35
|
+
sourceAgentId: string,
|
|
36
|
+
query: string,
|
|
37
|
+
): TargetResolution {
|
|
38
|
+
const needle = query.trim().replace(/^@/, "").toLowerCase();
|
|
39
|
+
if (!needle) return { kind: "none" };
|
|
40
|
+
const candidates = entries.filter((entry) => isMessageable(entry, sourceAgentId));
|
|
41
|
+
const idMatches = candidates.filter((entry) => entry.agent.id.toLowerCase().startsWith(needle));
|
|
42
|
+
if (idMatches.length === 1) return { kind: "match", entry: idMatches[0] as AgentEntry };
|
|
43
|
+
if (idMatches.length > 1) return { kind: "ambiguous", entries: idMatches };
|
|
44
|
+
const titleMatches = candidates.filter((entry) => title(entry).toLowerCase() === needle);
|
|
45
|
+
if (titleMatches.length === 1) return { kind: "match", entry: titleMatches[0] as AgentEntry };
|
|
46
|
+
if (titleMatches.length > 1) return { kind: "ambiguous", entries: titleMatches };
|
|
47
|
+
const fuzzyMatches = messageTargets(candidates, sourceAgentId, needle);
|
|
48
|
+
if (fuzzyMatches.length === 1) return { kind: "match", entry: fuzzyMatches[0] as AgentEntry };
|
|
49
|
+
if (fuzzyMatches.length > 1) return { kind: "ambiguous", entries: fuzzyMatches };
|
|
50
|
+
return { kind: "none" };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function parseTellArguments(args: string): TellArguments | null {
|
|
54
|
+
const separator = args.indexOf("::");
|
|
55
|
+
if (separator < 1) return null;
|
|
56
|
+
const target = args.slice(0, separator).trim();
|
|
57
|
+
const message = args.slice(separator + 2).trim();
|
|
58
|
+
return target && message ? { target, message } : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function formatTellInstruction(targetAgentId: string, message: string): string {
|
|
62
|
+
return `tell ${targetAgentId}: ${message.trim()}`;
|
|
63
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { PluginAgentCommandContext } from "@getpaseo/plugin/client";
|
|
2
|
+
import { loadAgents, placement, title } from "./agents";
|
|
3
|
+
import { formatTellInstruction, parseTellArguments, resolveMessageTarget } from "./messaging";
|
|
4
|
+
|
|
5
|
+
export async function handleTellCommand({
|
|
6
|
+
args,
|
|
7
|
+
agent,
|
|
8
|
+
paseo,
|
|
9
|
+
openPanel,
|
|
10
|
+
}: PluginAgentCommandContext & { args: string }) {
|
|
11
|
+
if (!args.trim()) {
|
|
12
|
+
openPanel("tell-agent");
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const parsed = parseTellArguments(args);
|
|
16
|
+
if (!parsed) {
|
|
17
|
+
throw new Error("Usage: /tell <agent or workspace> :: <message>");
|
|
18
|
+
}
|
|
19
|
+
const { entries } = await loadAgents(paseo);
|
|
20
|
+
const resolution = resolveMessageTarget(entries, agent.id, parsed.target);
|
|
21
|
+
if (resolution.kind === "none") {
|
|
22
|
+
throw new Error(`No active agent matches “${parsed.target}”.`);
|
|
23
|
+
}
|
|
24
|
+
if (resolution.kind === "ambiguous") {
|
|
25
|
+
const examples = resolution.entries
|
|
26
|
+
.slice(0, 3)
|
|
27
|
+
.map((entry) => `${title(entry)} (${placement(entry)})`)
|
|
28
|
+
.join(", ");
|
|
29
|
+
throw new Error(`More than one agent matches “${parsed.target}”: ${examples}. Refine it.`);
|
|
30
|
+
}
|
|
31
|
+
const target = resolution.entry;
|
|
32
|
+
await paseo.agents.ref(agent.id).send(formatTellInstruction(target.agent.id, parsed.message));
|
|
33
|
+
}
|
package/index.client.tsx
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@omercnet/paseo-tell-agent",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Tell agents in other Paseo workspaces.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"packageManager": "bun@1.4.0",
|
|
11
|
+
"engines": {
|
|
12
|
+
"bun": ">=1.4.0"
|
|
13
|
+
},
|
|
14
|
+
"author": "Omer Cohen",
|
|
15
|
+
"keywords": [
|
|
16
|
+
"paseo",
|
|
17
|
+
"paseo-plugin",
|
|
18
|
+
"tell-agent",
|
|
19
|
+
"coding-agents",
|
|
20
|
+
"orchestration"
|
|
21
|
+
],
|
|
22
|
+
"files": [
|
|
23
|
+
"index.client.tsx",
|
|
24
|
+
"client",
|
|
25
|
+
"paseo-plugin.json",
|
|
26
|
+
"LICENSE"
|
|
27
|
+
],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"check": "biome check .",
|
|
30
|
+
"check:write": "biome check --write .",
|
|
31
|
+
"test": "bun test",
|
|
32
|
+
"typecheck": "tsc --noEmit"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@biomejs/biome": "^2.5.10",
|
|
36
|
+
"@getpaseo/client": "0.8.0",
|
|
37
|
+
"@getpaseo/plugin": "0.8.0",
|
|
38
|
+
"@getpaseo/protocol": "0.8.0",
|
|
39
|
+
"@tanstack/react-query": "^5.102.3",
|
|
40
|
+
"@types/bun": "^1.4.0",
|
|
41
|
+
"@types/react": "^19.2.18",
|
|
42
|
+
"react": "19.1.0",
|
|
43
|
+
"react-native": "0.81.5",
|
|
44
|
+
"typescript": "^7.0.2"
|
|
45
|
+
}
|
|
46
|
+
}
|