channels.tools 0.1.4 → 0.1.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channels.tools",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Channels for the pi coding agent: connect channel servers that push events into a live session — wake it when idle, queue when busy — and proxy their tools. A client for the claude/channel convention.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.test.ts CHANGED
@@ -30,7 +30,11 @@ function fakePi() {
30
30
  sendMessage(message: unknown, opts: unknown) { customs.push({ msg: message, opts }); },
31
31
  registerTool(def: ToolDef) { registered.push(def.name); tools.set(def.name, def); },
32
32
  unregisterTool(name: string) { unregistered.push(name); tools.delete(name); },
33
- getAllTools() { return [{ name: "bash" }, { name: "read" }]; },
33
+ // Faithful to pi 0.84.4: getAllTools() reads the full _toolDefinitions
34
+ // registry, so a tool this extension registered on a prior pass is still
35
+ // returned here (there is no working unregisterTool to evict it). A static
36
+ // [bash, read] would hide the reload self-collision this guards against.
37
+ getAllTools() { return [{ name: "bash" }, { name: "read" }, ...[...tools.keys()].map((name) => ({ name }))]; },
34
38
  getActiveTools() { return ["bash", "read"]; },
35
39
  setActiveTools(_names: string[]) {},
36
40
  };
@@ -161,6 +165,38 @@ test("a tool still works after a resume re-registers it against a fresh connecti
161
165
  await fire("session_shutdown");
162
166
  });
163
167
 
168
+ test("a reload does not self-collide: the second pass re-registers the bare name, dropping nothing", async () => {
169
+ // The reload self-collision: pi.getAllTools() returns this extension's own
170
+ // prior registration (no working unregisterTool in 0.84.4), so a naive
171
+ // builtins baseline mistakes the leaked fake_status for a builtin, prefixes
172
+ // to fake_fake_status, and after enough reloads even the prefixed name is
173
+ // taken and the tool is dropped — with a misleading "collides with a builtin"
174
+ // reason. The fix excludes the extension's own names from the baseline.
175
+ const { pi, fire, tools, registered } = fakePi();
176
+ createExtension(pi as never, {
177
+ env: {},
178
+ loadConfig: () => ({ fake: { command: process.execPath, args: [FAKE] } }),
179
+ });
180
+ await fire("session_start", { reason: "startup" });
181
+ await new Promise((r) => setTimeout(r, 300));
182
+ // Two more reloads: without the fix, getAllTools() now reports fake_status,
183
+ // so the second pass prefixes and the third drops the prefixed name too.
184
+ await fire("session_start", { reason: "resume" });
185
+ await new Promise((r) => setTimeout(r, 300));
186
+ await fire("session_start", { reason: "resume" });
187
+ await new Promise((r) => setTimeout(r, 300));
188
+ assert.ok(tools.has("fake_status"), "the bare name must survive every reload");
189
+ assert.ok(!tools.has("fake_fake_status"), "the extension must never prefix against its own prior registration");
190
+ assert.ok(
191
+ !registered.includes("fake_fake_status"),
192
+ `no reload may register a self-prefixed name; registered: ${registered.join(", ")}`,
193
+ );
194
+ const tool = tools.get("fake_status");
195
+ const result = (await tool!.execute("call-1", {})) as { content: Array<{ text: string }> };
196
+ assert.deepEqual(result.content, [{ type: "text", text: "fake ok" }], "the bare tool must still work after reloads");
197
+ await fire("session_shutdown");
198
+ });
199
+
164
200
  test("a tool result with isError:true is signaled by throwing, not returned silently", async () => {
165
201
  const { pi, fire, tools } = fakePi();
166
202
  createExtension(pi as never, {
package/src/index.ts CHANGED
@@ -77,6 +77,14 @@ export function createExtension(pi: any, deps: Deps = {}): void {
77
77
  }
78
78
  | undefined;
79
79
  const registeredNames = new Set<string>();
80
+ // Every name this extension has EVER registered, never cleared. pi 0.84.4
81
+ // has no working unregisterTool, so getAllTools() keeps returning our own
82
+ // prior registrations across reload/resume/fork. Without excluding them from
83
+ // the builtins baseline below, registerTools mistakes its own leaked tool
84
+ // for a builtin, prefixes it, and eventually drops it ("collides with a
85
+ // builtin"). registerTool replaces by name, so re-registering the bare
86
+ // self-namespaced name every reload is correct.
87
+ const ownNames = new Set<string>();
80
88
 
81
89
  function uiSetStatus(status: string): void {
82
90
  ui?.setStatus?.("channels", status === "" ? undefined : status);
@@ -91,7 +99,9 @@ export function createExtension(pi: any, deps: Deps = {}): void {
91
99
  }
92
100
 
93
101
  function registerTools(connections: Connection[]): void {
94
- const builtins = (pi.getAllTools?.() ?? []).map((t: { name: string }) => t.name);
102
+ const builtins = (pi.getAllTools?.() ?? [])
103
+ .map((t: { name: string }) => t.name)
104
+ .filter((name: string) => !ownNames.has(name));
95
105
  const { resolved, dropped } = resolveToolNames(
96
106
  connections.map((c) => ({ name: c.name, tools: c.tools })),
97
107
  builtins,
@@ -143,6 +153,7 @@ export function createExtension(pi: any, deps: Deps = {}): void {
143
153
  // built from `connections.map(...)` above.
144
154
  const conn = connections.find((c) => c.name === entry.server)!;
145
155
  const def = conn.tools.find((t) => t.name === entry.original);
156
+ ownNames.add(entry.registered);
146
157
  registeredNames.add(entry.registered);
147
158
  pi.registerTool({
148
159
  name: entry.registered,