@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 +12 -0
- package/client/agents.ts +104 -0
- package/client/pill.tsx +52 -22
- package/package.json +1 -1
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
|
package/client/agents.ts
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
.
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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.
|
|
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",
|