@gpambrozio/paseo-skills 0.2.0 → 0.2.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/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Every version
7
7
  as `@gpambrozio/paseo-skills` and tagged here, so a version is something to install and a line to
8
8
  read before you move.
9
9
 
10
+ ## [0.2.1] — 2026-09-23
11
+
12
+ ### Fixed
13
+
14
+ - **The Skills pill reaches agents created after the plugin loaded.** Paseo 0.9 changed
15
+ `agents.subscribe()` to a local listener that no longer asks the daemon for agent data, so the
16
+ pill only covered agents that existed at load; a new session had none until the plugin was
17
+ reloaded. On 0.9 clients the pill now opens its own agent observation
18
+ (`agents.list({ subscribe: {} })`) and follows its snapshots and updates; an 0.8 client keeps the
19
+ previous listen-and-seed behaviour, because sending `subscribe` from there would replace the
20
+ app's own agent subscription.
21
+
10
22
  ## [0.2.0] — 2026-09-22
11
23
 
12
24
  ### Added
@@ -0,0 +1,104 @@
1
+ import type { PluginClientContext } from "@getpaseo/plugin/client";
2
+
3
+ /**
4
+ * Following the host's agents across Paseo 0.8 and 0.9 clients.
5
+ *
6
+ * Since 0.9, `agents.subscribe()` only adds a local listener to observations
7
+ * that the same API instance opened with `agents.list({ subscribe: {} })`; on
8
+ * its own it never hears anything. The observation delivers a snapshot first
9
+ * and again after every reconnect, then the updates in between.
10
+ *
11
+ * A 0.8 client has no observations, and must not send `subscribe` either: the
12
+ * daemon keeps one agents subscription slot per legacy connection, last query
13
+ * wins, so a plugin asking for one would replace the app's own. The choice is
14
+ * therefore made before any request, from the API's shape.
15
+ */
16
+
17
+ type Paseo = PluginClientContext["paseo"];
18
+ type AgentListOptions = NonNullable<Parameters<Paseo["agents"]["list"]>[0]>;
19
+ export type AgentList = Awaited<ReturnType<Paseo["agents"]["list"]>>;
20
+ export type AgentUpdate = Parameters<Parameters<Paseo["agents"]["subscribe"]>[0]>[0];
21
+
22
+ /** The 0.9 observation handle; the 0.8 typings this plugin builds against predate it. */
23
+ type AgentObservation = {
24
+ subscribe(observer: {
25
+ snapshot(snapshot: AgentList): void;
26
+ update(message: { type: string; payload?: unknown }): void;
27
+ error?(error: unknown): void;
28
+ }): () => void;
29
+ release(): Promise<void>;
30
+ };
31
+ type ObservingAgents = {
32
+ list(
33
+ options: AgentListOptions & { subscribe: {}; signal: AbortSignal },
34
+ ): Promise<AgentList & { subscription?: AgentObservation }>;
35
+ };
36
+
37
+ const RETRY_MIN_MS = 2_000;
38
+ const RETRY_MAX_MS = 60_000;
39
+
40
+ /** `observeEvents` shipped together with observations (0.9.0-beta.1). */
41
+ export function canObserveAgents(paseo: Paseo): boolean {
42
+ return typeof (paseo as { observeEvents?: unknown }).observeEvents === "function";
43
+ }
44
+
45
+ /**
46
+ * On a 0.9 client, keeps an agent observation open for the caller's lifetime:
47
+ * `snapshot` replaces the caller's view, `update` applies one change. Paseo
48
+ * releases an observation that fails (a re-request after reconnect, say), so it
49
+ * is reopened with backoff rather than left silent. On a 0.8 client it runs
50
+ * `legacy` instead, the pre-0.9 listener and read. Returns the cleanup.
51
+ */
52
+ export function followAgents(
53
+ paseo: Paseo,
54
+ handlers: { snapshot(list: AgentList): void; update(update: AgentUpdate): void },
55
+ legacy: () => () => void,
56
+ ): () => void {
57
+ if (!canObserveAgents(paseo)) return legacy();
58
+
59
+ const lifetime = new AbortController();
60
+ let observation: AgentObservation | null = null;
61
+ let retry: ReturnType<typeof setTimeout> | null = null;
62
+ let retryDelay = RETRY_MIN_MS;
63
+
64
+ function reopen(error: unknown): void {
65
+ observation = null;
66
+ if (lifetime.signal.aborted || retry !== null) return;
67
+ console.warn("skills: agent observation failed; reopening", error);
68
+ retry = setTimeout(() => {
69
+ retry = null;
70
+ open();
71
+ }, retryDelay);
72
+ retryDelay = Math.min(retryDelay * 2, RETRY_MAX_MS);
73
+ }
74
+
75
+ function open(): void {
76
+ (paseo.agents as unknown as ObservingAgents)
77
+ .list({ subscribe: {}, signal: lifetime.signal })
78
+ .then(({ subscription }) => {
79
+ if (lifetime.signal.aborted) return;
80
+ if (subscription === undefined) throw new Error("the host returned no agent observation");
81
+ observation = subscription;
82
+ subscription.subscribe({
83
+ snapshot(list) {
84
+ retryDelay = RETRY_MIN_MS;
85
+ handlers.snapshot(list);
86
+ },
87
+ update(message) {
88
+ if (message.type === "agent_update") handlers.update(message.payload as AgentUpdate);
89
+ },
90
+ error: reopen,
91
+ });
92
+ })
93
+ .catch(reopen);
94
+ }
95
+
96
+ open();
97
+ return () => {
98
+ lifetime.abort();
99
+ if (retry !== null) clearTimeout(retry);
100
+ retry = null;
101
+ void observation?.release().catch(() => undefined);
102
+ observation = null;
103
+ };
104
+ }
package/client/pill.tsx CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  import { Icon } from "@getpaseo/plugin/client/react-native";
7
7
  import { useEffect } from "react";
8
8
 
9
+ import { followAgents, type AgentList, type AgentUpdate } from "./agents";
9
10
  import { countEntries, useSkillsQuery } from "./skills-query";
10
11
 
11
12
  /** What the pill reads before the count is known, and the accessible name throughout. */
@@ -49,13 +50,18 @@ function createPillIcon(agentId: string, pill: { current?: PluginButtonRegistrat
49
50
  * stream for the rest, and removes every registration on teardown.
50
51
  */
51
52
  export function contributePills(client: PluginClientContext) {
52
- const pills = new Map<string, PluginButtonRegistration>();
53
+ const pills = new Map<string, { workspaceId: string; registration: PluginButtonRegistration }>();
54
+ let stopped = false;
53
55
 
54
56
  function addPill(agentId: string, workspaceId: string) {
57
+ if (stopped) return;
55
58
  // Agent updates fire on every turn of every agent. Nothing in the pill
56
59
  // depends on the snapshot, so re-registering would only unmount the icon
57
- // and refire its query — and Paseo rejects a duplicate id outright.
58
- if (pills.has(agentId)) return;
60
+ // and refire its query — and Paseo rejects a duplicate id outright. Only a
61
+ // move to another workspace needs a new registration, since it is baked in.
62
+ const current = pills.get(agentId);
63
+ if (current?.workspaceId === workspaceId) return;
64
+ if (current) removePill(agentId);
59
65
 
60
66
  // The icon needs the registration that is about to be created from it, so
61
67
  // it reaches the registration through this box rather than through a prop.
@@ -76,41 +82,65 @@ export function contributePills(client: PluginClientContext) {
76
82
  },
77
83
  },
78
84
  });
79
- pills.set(agentId, pill.current);
85
+ pills.set(agentId, { workspaceId, registration: pill.current });
80
86
  }
81
87
 
82
88
  function removePill(agentId: string) {
83
- pills.get(agentId)?.remove();
89
+ pills.get(agentId)?.registration.remove();
84
90
  pills.delete(agentId);
85
91
  }
86
92
 
87
- const unsubscribe = client.paseo.agents.subscribe((update) => {
93
+ function applyUpdate(update: AgentUpdate) {
88
94
  if (update.kind === "remove") {
89
95
  removePill(update.agentId);
90
96
  return;
91
97
  }
92
98
  const { id, workspaceId } = update.agent;
93
99
  if (workspaceId) addPill(id, workspaceId);
94
- });
100
+ }
95
101
 
96
- // `subscribe` only reports change. Without this seed, an agent that was
97
- // already sitting idle when the app connected would have no pill until it
98
- // next did something.
99
- client.paseo.agents
100
- .list()
101
- .then((result) => {
102
- // A `for…of` body would capture the loop binding, not the entry.
103
- result.entries.forEach(({ agent }) => {
104
- if (agent.workspaceId) addPill(agent.id, agent.workspaceId);
105
- });
106
- })
107
- .catch((error: unknown) => {
108
- console.error("skills: could not seed composer pills", error);
102
+ // A snapshot arrives first and again after every reconnect, and replaces the
103
+ // set: agents it no longer lists lose their pill, unchanged ones keep theirs.
104
+ function applySnapshot(list: AgentList) {
105
+ if (stopped) return;
106
+ const listed = new Map<string, string>();
107
+ list.entries.forEach(({ agent }) => {
108
+ if (agent.workspaceId) listed.set(agent.id, agent.workspaceId);
109
+ });
110
+ [...pills.keys()].forEach((agentId) => {
111
+ if (!listed.has(agentId)) removePill(agentId);
109
112
  });
113
+ listed.forEach((workspaceId, agentId) => addPill(agentId, workspaceId));
114
+ }
115
+
116
+ const unfollow = followAgents(
117
+ client.paseo,
118
+ { snapshot: applySnapshot, update: applyUpdate },
119
+ () => {
120
+ const unsubscribe = client.paseo.agents.subscribe(applyUpdate);
121
+
122
+ // `subscribe` only reports change. Without this seed, an agent that was
123
+ // already sitting idle when the app connected would have no pill until it
124
+ // next did something.
125
+ client.paseo.agents
126
+ .list()
127
+ .then((result) => {
128
+ // A `for…of` body would capture the loop binding, not the entry.
129
+ result.entries.forEach(({ agent }) => {
130
+ if (agent.workspaceId) addPill(agent.id, agent.workspaceId);
131
+ });
132
+ })
133
+ .catch((error: unknown) => {
134
+ console.error("skills: could not seed composer pills", error);
135
+ });
136
+ return unsubscribe;
137
+ },
138
+ );
110
139
 
111
140
  return () => {
112
- unsubscribe();
113
- pills.forEach((pill) => pill.remove());
141
+ stopped = true;
142
+ unfollow();
143
+ pills.forEach((pill) => pill.registration.remove());
114
144
  pills.clear();
115
145
  };
116
146
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gpambrozio/paseo-skills",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Paseo plugin: lists the skills an agent can use, shows where each comes from, renders its SKILL.md, and invokes it",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/gpambrozio/paseo-plugins/tree/main/skills",