@omercnet/paseo-tell-agent 0.3.0 → 1.0.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/README.md CHANGED
@@ -2,39 +2,37 @@
2
2
 
3
3
  Send a message from one Paseo agent to another agent or workspace on the same daemon host.
4
4
 
5
- ## Install
5
+ ## Screenshots
6
6
 
7
- ### Paseo 0.9.0-beta.1 or later
7
+ Agent and workspace names are synthetic. Both PNGs were captured from an isolated Paseo test
8
+ daemon at 2× pixel density after browser DevTools verified that no private organization names
9
+ remained in the rendered page.
8
10
 
9
- Install the npm package:
11
+ ### Choose a target
10
12
 
11
- ```bash
12
- paseo plugin install npm:@omercnet/paseo-tell-agent
13
- ```
13
+ ![Tell Agent target picker](https://raw.githubusercontent.com/omercnet/paseo-plugins/main/tell-agent/docs/images/tell-agent-picker.png)
14
14
 
15
- Update an npm installation by its plugin ID:
15
+ ### Compose an instruction
16
16
 
17
- ```bash
18
- paseo plugin update tell-agent
19
- ```
17
+ ![Tell Agent instruction composer](https://raw.githubusercontent.com/omercnet/paseo-plugins/main/tell-agent/docs/images/tell-agent-compose.png)
20
18
 
21
- Paseo 0.9's reviewed update flow shows the current and proposed revisions before asking for approval. Add `--check` to preview without applying or `--yes` to skip the approval prompt. npm installation and this reviewed update behavior are not available in Paseo 0.8.
19
+ ## Install
22
20
 
23
- ### Paseo 0.8.x
21
+ ### Paseo 0.9.0 or later
24
22
 
25
- The manifest remains compatible with Paseo 0.8. Install from the Git repository:
23
+ Install the npm package:
26
24
 
27
25
  ```bash
28
- paseo plugin add omercnet/paseo-plugins:tell-agent
26
+ paseo plugin install npm:@omercnet/paseo-tell-agent
29
27
  ```
30
28
 
31
- Or install a local checkout on the daemon host:
29
+ Update an npm installation by its plugin ID:
32
30
 
33
31
  ```bash
34
- paseo plugin install /absolute/path/to/paseo-plugins/tell-agent
32
+ paseo plugin update tell-agent
35
33
  ```
36
34
 
37
- Use `paseo plugin update tell-agent` to refresh a Git installation. After changing a local checkout, use `paseo plugin reload tell-agent`.
35
+ Paseo 0.9's reviewed update flow shows the current and proposed revisions before asking for approval. Add `--check` to preview without applying or `--yes` to skip the approval prompt.
38
36
 
39
37
  ## Use
40
38
 
package/client/agents.ts CHANGED
@@ -1,10 +1,25 @@
1
- import type { PaseoAgent, PaseoAgentListResult, PaseoApi } from "@getpaseo/client";
1
+ import type { PluginClientContext } from "@getpaseo/plugin/client";
2
2
 
3
- export type AgentEntry = PaseoAgentListResult["entries"][number];
4
- type AgentSnapshot = PaseoAgent;
3
+ type PaseoApi = PluginClientContext["paseo"];
4
+ type PaseoAgentUpdate = Parameters<Parameters<PaseoApi["agents"]["subscribe"]>[0]>[0];
5
+ type AgentSnapshot = Extract<PaseoAgentUpdate, { kind: "upsert" }>["agent"];
6
+ export type AgentEntry = {
7
+ agent: AgentSnapshot;
8
+ project: {
9
+ projectName: string;
10
+ workspaceName?: string | null;
11
+ checkout: { isGit: boolean; currentBranch: string | null };
12
+ };
13
+ };
14
+ type PaseoAgentListResult = {
15
+ entries: AgentEntry[];
16
+ pageInfo: { hasMore: boolean; nextCursor: string | null };
17
+ };
5
18
 
6
19
  export const AGENT_PAGE_LIMIT = 200;
7
20
  export const MAX_AGENT_PAGES = 10;
21
+ const REOPEN_MIN_MS = 2_000;
22
+ const REOPEN_MAX_MS = 60_000;
8
23
 
9
24
  export type AgentDirectoryPage = {
10
25
  entries: AgentEntry[];
@@ -26,6 +41,146 @@ export async function loadAgents(paseo: PaseoApi): Promise<AgentDirectoryPage> {
26
41
  return { entries, truncated: true };
27
42
  }
28
43
 
44
+ /**
45
+ * Continues `loadAgents` from an observation's first page, to the same cap.
46
+ * Returns null once `current()` turns false, so a stale read is dropped.
47
+ */
48
+ async function readRemainingPages(
49
+ paseo: PaseoApi,
50
+ first: PaseoAgentListResult,
51
+ signal: AbortSignal,
52
+ current: () => boolean,
53
+ ): Promise<AgentDirectoryPage | null> {
54
+ const entries = [...first.entries];
55
+ let cursor = first.pageInfo.hasMore ? (first.pageInfo.nextCursor ?? undefined) : undefined;
56
+ for (let page = 1; cursor; page += 1) {
57
+ if (page >= MAX_AGENT_PAGES) return { entries, truncated: true };
58
+ const result = await paseo.agents.list({
59
+ sort: [{ key: "updated_at", direction: "desc" }],
60
+ page: { limit: AGENT_PAGE_LIMIT, cursor },
61
+ signal,
62
+ });
63
+ if (!current()) return null;
64
+ entries.push(...result.entries);
65
+ cursor = result.pageInfo.hasMore ? (result.pageInfo.nextCursor ?? undefined) : undefined;
66
+ }
67
+ return { entries, truncated: false };
68
+ }
69
+
70
+ export type AgentDirectoryFollower = {
71
+ /**
72
+ * The directory as listed. `complete` means it holds every agent the host
73
+ * has, so anything missing is gone; otherwise apply it as upserts only.
74
+ */
75
+ snapshot(agents: AgentSnapshot[], complete: boolean): void;
76
+ upsert(agent: AgentSnapshot): void;
77
+ remove(agentId: string): void;
78
+ };
79
+
80
+ /**
81
+ * Keeps `follower` in step with the host's agents until the returned cleanup.
82
+ *
83
+ * `agents.subscribe()` only hears observations the same API instance opened,
84
+ * so this opens one with `list({ subscribe: {} })`. Its snapshot, the first
85
+ * page, arrives first and again after every reconnect; later pages are read
86
+ * plainly, and updates that land meanwhile win over what those pages say.
87
+ * Paseo releases an observation that fails, so it is reopened with backoff.
88
+ */
89
+ export function followAgentDirectory(
90
+ paseo: PaseoApi,
91
+ follower: AgentDirectoryFollower,
92
+ ): () => void {
93
+ let stopped = false;
94
+ const apply = (update: PaseoAgentUpdate) => {
95
+ if (update.kind === "remove") follower.remove(update.agentId);
96
+ else follower.upsert(update.agent);
97
+ };
98
+
99
+ const lifetime = new AbortController();
100
+ let observation: { release(): Promise<void> } | null = null;
101
+ let reopenTimer: ReturnType<typeof setTimeout> | null = null;
102
+ let reopenDelay = REOPEN_MIN_MS;
103
+ /** Bumped per snapshot, so a reconnect abandons the paging of the one before. */
104
+ let generation = 0;
105
+ /** What updates said while the current snapshot's later pages were read. */
106
+ let landed: Map<string, AgentSnapshot | null> | null = null;
107
+
108
+ const applySnapshot = async (first: PaseoAgentListResult) => {
109
+ const current = ++generation;
110
+ const isCurrent = () => !stopped && generation === current;
111
+ const touched = new Map<string, AgentSnapshot | null>();
112
+ landed = touched;
113
+ let listed: AgentDirectoryPage | null;
114
+ try {
115
+ listed = await readRemainingPages(paseo, first, lifetime.signal, isCurrent);
116
+ } catch {
117
+ listed = { entries: [...first.entries], truncated: true };
118
+ }
119
+ if (!listed || !isCurrent()) return;
120
+ landed = null;
121
+ const agents = new Map(listed.entries.map((entry) => [entry.agent.id, entry.agent]));
122
+ for (const [agentId, agent] of touched) {
123
+ if (agent) agents.set(agentId, agent);
124
+ else agents.delete(agentId);
125
+ }
126
+ follower.snapshot([...agents.values()], !listed.truncated);
127
+ };
128
+
129
+ const reopen = () => {
130
+ observation = null;
131
+ generation += 1;
132
+ landed = null;
133
+ if (stopped || reopenTimer !== null) return;
134
+ reopenTimer = setTimeout(() => {
135
+ reopenTimer = null;
136
+ open();
137
+ }, reopenDelay);
138
+ reopenDelay = Math.min(reopenDelay * 2, REOPEN_MAX_MS);
139
+ };
140
+
141
+ const open = () => {
142
+ paseo.agents
143
+ .list({
144
+ sort: [{ key: "updated_at", direction: "desc" }],
145
+ page: { limit: AGENT_PAGE_LIMIT },
146
+ subscribe: {},
147
+ signal: lifetime.signal,
148
+ })
149
+ .then(({ subscription }) => {
150
+ if (stopped) {
151
+ void subscription.release().catch(() => undefined);
152
+ return;
153
+ }
154
+ observation = subscription;
155
+ subscription.subscribe({
156
+ snapshot: (first) => {
157
+ reopenDelay = REOPEN_MIN_MS;
158
+ void applySnapshot(first);
159
+ },
160
+ update: (message) => {
161
+ if (stopped || message.type !== "agent_update") return;
162
+ const update = message.payload;
163
+ if (update.kind === "remove") landed?.set(update.agentId, null);
164
+ else landed?.set(update.agent.id, update.agent);
165
+ apply(update);
166
+ },
167
+ error: reopen,
168
+ });
169
+ })
170
+ .catch(reopen);
171
+ };
172
+
173
+ open();
174
+ return () => {
175
+ stopped = true;
176
+ lifetime.abort();
177
+ if (reopenTimer !== null) clearTimeout(reopenTimer);
178
+ reopenTimer = null;
179
+ void observation?.release().catch(() => undefined);
180
+ observation = null;
181
+ };
182
+ }
183
+
29
184
  export function title(entry: AgentEntry): string {
30
185
  const explicit = entry.agent.title?.trim();
31
186
  return explicit && explicit.length > 0 ? explicit : entry.agent.id.slice(0, 7);
@@ -11,6 +11,7 @@ import { Pressable, ScrollView, Text, TextInput, View } from "react-native";
11
11
  import {
12
12
  AGENT_PAGE_LIMIT,
13
13
  type AgentEntry,
14
+ followAgentDirectory,
14
15
  loadAgents,
15
16
  MAX_AGENT_PAGES,
16
17
  placement,
@@ -328,20 +329,18 @@ export function contributeAgentMessaging(client: PluginClientContext) {
328
329
  pills.set(agent.id, { workspaceId: next.workspaceId, remove: pill.remove });
329
330
  }
330
331
 
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 }) => {
332
+ const unsubscribe = followAgentDirectory(client.paseo, {
333
+ snapshot(agents, complete) {
341
334
  if (stopped) return;
342
- for (const { agent } of entries) syncAgent(agent);
343
- })
344
- .catch(() => undefined);
335
+ if (complete) {
336
+ const listed = new Set(agents.map((agent) => agent.id));
337
+ for (const agentId of [...pills.keys()]) if (!listed.has(agentId)) removePill(agentId);
338
+ }
339
+ for (const agent of agents) syncAgent(agent);
340
+ },
341
+ upsert: syncAgent,
342
+ remove: removePill,
343
+ });
345
344
 
346
345
  const removeTellCommand = client.addSlashCommand({
347
346
  name: "tell",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omercnet/paseo-tell-agent",
3
- "version": "0.3.0",
3
+ "version": "1.0.0",
4
4
  "type": "module",
5
5
  "description": "Send messages to agents in other workspaces on the same Paseo host.",
6
6
  "license": "MIT",
@@ -16,7 +16,7 @@
16
16
  "publishConfig": {
17
17
  "access": "public"
18
18
  },
19
- "packageManager": "npm@11.19.1",
19
+ "packageManager": "bun@1.4.2",
20
20
  "engines": {
21
21
  "node": ">=24"
22
22
  },
@@ -43,11 +43,11 @@
43
43
  },
44
44
  "devDependencies": {
45
45
  "@biomejs/biome": "^2.5.10",
46
- "@getpaseo/client": "0.9.0-beta.1",
47
- "@getpaseo/plugin": "0.9.0-beta.1",
48
- "@getpaseo/protocol": "0.9.0-beta.1",
46
+ "@getpaseo/client": "0.9.0",
47
+ "@getpaseo/plugin": "0.9.0",
48
+ "@getpaseo/protocol": "0.9.0",
49
49
  "@tanstack/react-query": "^5.102.3",
50
- "@types/node": "^24.5.2",
50
+ "@types/node": "^24.10.1",
51
51
  "@types/react": "^19.2.18",
52
52
  "react": "19.1.0",
53
53
  "react-native": "0.81.5",
package/paseo-plugin.json CHANGED
@@ -1,4 +1,5 @@
1
1
  {
2
2
  "id": "tell-agent",
3
- "requirements": { "paseo": "^0.8.0 || ^0.9.0-beta.1" }
3
+ "description": "Send messages to agents in other workspaces on the same Paseo host.",
4
+ "requirements": { "paseo": "^0.9.0" }
4
5
  }