@zooid/pi-extension 0.13.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/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@zooid/pi-extension",
3
+ "version": "0.13.1",
4
+ "description": "Native Pi extension exposing Zooid context and task tools.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "main": "./src/index.ts",
8
+ "types": "./src/index.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./src/index.ts",
12
+ "import": "./src/index.ts"
13
+ },
14
+ "./bundle": "./dist/zooid-tasks.js"
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "src",
19
+ "README.md"
20
+ ],
21
+ "dependencies": {
22
+ "@zooid/context-mcp": "^0.14.0"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^22.0.0",
26
+ "tsup": "^8.5.1",
27
+ "typescript": "^5.5.0",
28
+ "vitest": "^3.2.0"
29
+ },
30
+ "scripts": {
31
+ "build": "tsup",
32
+ "test": "vitest run",
33
+ "typecheck": "tsc --noEmit"
34
+ }
35
+ }
@@ -0,0 +1,123 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import createExtension from './extension.js'
3
+
4
+ function harness(reply: unknown = { ok: true, result: { messages: [] } }) {
5
+ const tools: Array<any> = []
6
+ const handlers: Record<string, Function> = {}
7
+ let active: string[] = []
8
+ const pi = {
9
+ registerTool: (tool: any) => {
10
+ tools.push(tool)
11
+ active.push(tool.name)
12
+ },
13
+ on: (event: string, handler: Function) => {
14
+ handlers[event] = handler
15
+ },
16
+ getActiveTools: () => active,
17
+ setActiveTools: (names: string[]) => {
18
+ active = names
19
+ },
20
+ getAllTools: () => tools.map((t) => ({ name: t.name })),
21
+ }
22
+ const calls: Array<any> = []
23
+ createExtension(pi as any, {
24
+ resolve: async (request) => {
25
+ calls.push(request)
26
+ return reply as any
27
+ },
28
+ })
29
+ return { tools, calls, handlers, activeTools: () => active }
30
+ }
31
+ const ctx = (sessionId = 'session-a') => ({ sessionManager: { getSessionId: () => sessionId } })
32
+
33
+ describe('Pi Zooid extension', () => {
34
+ it('registers the renamed surface plus the two new tools', () => {
35
+ const { tools } = harness()
36
+ expect(tools.map((t) => t.name).sort()).toEqual([
37
+ 'zooid_complete_task', 'zooid_get_history', 'zooid_get_members',
38
+ 'zooid_get_recent_threads', 'zooid_get_room_info', 'zooid_get_rooms',
39
+ 'zooid_get_thread_history', 'zooid_send_message', 'zooid_start_task_threads',
40
+ ])
41
+ })
42
+
43
+ it('carries the wake contract in promptGuidelines', () => {
44
+ const { tools } = harness()
45
+ const start = tools.find((t) => t.name === 'zooid_start_task_threads')
46
+ expect(start.promptGuidelines.join(' ')).toMatch(/zooid_start_task_threads/)
47
+ expect(start.promptGuidelines.join(' ')).toMatch(/end your turn/i)
48
+ })
49
+
50
+ it('uses the session from ctx for every call and defaults task notifications', async () => {
51
+ const { tools, calls } = harness({ ok: true, result: { results: [] } })
52
+ await tools.find((tool) => tool.name === 'zooid_start_task_threads').execute(
53
+ 'call', { tasks: [{ agent: 'reviewer', prompt: 'review this' }] }, undefined, undefined, ctx('alpha'),
54
+ )
55
+ await tools.find((tool) => tool.name === 'zooid_complete_task').execute(
56
+ 'call', { summary: 'done' }, undefined, undefined, ctx('beta'),
57
+ )
58
+ expect(calls).toEqual([
59
+ { acpSessionId: 'alpha', method: 'startTasks', params: { tasks: [{ agent: 'reviewer', prompt: 'review this' }], notify: 'caller' } },
60
+ { acpSessionId: 'beta', method: 'completeTask', params: { summary: 'done' } },
61
+ ])
62
+ })
63
+
64
+ it('maps read tools to the daemon methods and clamps pagination', async () => {
65
+ const { tools, calls } = harness()
66
+ await tools.find((tool) => tool.name === 'zooid_get_history').execute('c', { limit: 5000 }, undefined, undefined, ctx())
67
+ await tools.find((tool) => tool.name === 'zooid_get_thread_history').execute('c', { thread_id: '$root', before: 'cursor' }, undefined, undefined, ctx())
68
+ expect(calls).toEqual([
69
+ { acpSessionId: 'session-a', method: 'getRoomHistory', params: { limit: 200 } },
70
+ { acpSessionId: 'session-a', method: 'getThreadHistory', params: { threadId: '$root', limit: 50, before: 'cursor' } },
71
+ ])
72
+ })
73
+
74
+ it('zooid_send_message forwards room, thread_id and text', async () => {
75
+ const { tools, calls } = harness({ ok: true, result: { event_id: '$e' } })
76
+ await tools.find((tool) => tool.name === 'zooid_send_message').execute(
77
+ 'c', { room: '!a:localhost', thread_id: '$t', text: 'noted' }, undefined, undefined, ctx(),
78
+ )
79
+ expect(calls).toEqual([
80
+ { acpSessionId: 'session-a', method: 'sendMessage', params: { room: '!a:localhost', thread_id: '$t', text: 'noted' } },
81
+ ])
82
+ })
83
+
84
+ it('zooid_get_rooms maps to getRooms with no params', async () => {
85
+ const { tools, calls } = harness({ ok: true, result: { rooms: [] } })
86
+ await tools.find((tool) => tool.name === 'zooid_get_rooms').execute('c', {}, undefined, undefined, ctx())
87
+ expect(calls).toEqual([{ acpSessionId: 'session-a', method: 'getRooms', params: {} }])
88
+ })
89
+
90
+ it('returns readable errors for unbound sessions and invalid completion summaries', async () => {
91
+ const { tools } = harness({ ok: false, error: 'unknown session: orphan' })
92
+ const unbound = await tools.find((tool) => tool.name === 'zooid_get_members').execute('c', {}, undefined, undefined, ctx('orphan'))
93
+ const invalid = await tools.find((tool) => tool.name === 'zooid_complete_task').execute('c', { summary: ' ' }, undefined, undefined, ctx())
94
+ expect(unbound).toMatchObject({ isError: true })
95
+ expect(unbound.content[0].text).toMatch(/not bound/i)
96
+ expect(invalid).toMatchObject({ isError: true })
97
+ })
98
+
99
+ it('disables complete_task for a non-assignee on session_start', async () => {
100
+ const { handlers, activeTools } = harness({
101
+ ok: true, result: { is_task_assignee: false, can_start_task_threads: true },
102
+ })
103
+ await handlers.session_start?.({ reason: 'startup' }, ctx('alpha'))
104
+ expect(activeTools()).toContain('zooid_start_task_threads')
105
+ expect(activeTools()).not.toContain('zooid_complete_task')
106
+ })
107
+
108
+ it('disables start_task_threads for an assignee on session_start', async () => {
109
+ const { handlers, activeTools } = harness({
110
+ ok: true, result: { is_task_assignee: true, can_start_task_threads: false },
111
+ })
112
+ await handlers.session_start?.({ reason: 'startup' }, ctx('beta'))
113
+ expect(activeTools()).toContain('zooid_complete_task')
114
+ expect(activeTools()).not.toContain('zooid_start_task_threads')
115
+ })
116
+
117
+ it('leaves the surface untouched when the role query fails', async () => {
118
+ const { handlers, tools, activeTools } = harness({ ok: false, error: 'binding not owned by caller' })
119
+ await handlers.session_start?.({ reason: 'startup' }, ctx('gamma'))
120
+ // Fail open: a daemon hiccup must not strip an assignee's ability to finish.
121
+ expect(activeTools().length === 0 || activeTools().length === tools.length).toBe(true)
122
+ })
123
+ })
@@ -0,0 +1,188 @@
1
+ import { callDaemon, type DaemonRequest } from '@zooid/context-mcp'
2
+
3
+ interface ToolResult {
4
+ content: Array<{ type: 'text'; text: string }>
5
+ details?: Record<string, unknown>
6
+ isError?: boolean
7
+ }
8
+ interface ExtensionContext {
9
+ sessionManager?: { getSessionId?: () => string | undefined }
10
+ }
11
+ interface RegisteredTool {
12
+ name: string
13
+ label: string
14
+ description: string
15
+ parameters: unknown
16
+ /**
17
+ * Flat bullets appended to the system prompt while this tool is active —
18
+ * pi has no per-tool "Use this tool when…" slot, so anything that must
19
+ * name the tool explicitly (the wake contract) goes here.
20
+ */
21
+ promptGuidelines?: string[]
22
+ execute(
23
+ toolCallId: string,
24
+ params: Record<string, unknown>,
25
+ signal: AbortSignal | undefined,
26
+ onUpdate: ((partial: unknown) => void) | undefined,
27
+ ctx: ExtensionContext,
28
+ ): Promise<ToolResult>
29
+ }
30
+ interface ExtensionAPI {
31
+ registerTool(tool: RegisteredTool): void
32
+ /** Registered during load or after startup, including from a session_start handler. */
33
+ on(event: string, handler: (evt: unknown, ctx: ExtensionContext) => void | Promise<void>): void
34
+ getActiveTools(): string[]
35
+ setActiveTools(names: string[]): void
36
+ getAllTools(): Array<{ name: string }>
37
+ }
38
+
39
+ type DaemonReply = { ok: true; result: unknown } | { ok: false; error: string }
40
+ interface Deps {
41
+ resolve?: (request: DaemonRequest) => Promise<DaemonReply>
42
+ }
43
+
44
+ const DEFAULT_LIMIT = 50
45
+ const MAX_LIMIT = 200
46
+ const pageParameters = {
47
+ type: 'object',
48
+ properties: {
49
+ limit: { type: 'integer', minimum: 1 },
50
+ before: { type: 'string' },
51
+ },
52
+ } as const
53
+ const noParameters = { type: 'object', properties: {} } as const
54
+
55
+ function error(text: string): ToolResult {
56
+ return { content: [{ type: 'text', text }], isError: true }
57
+ }
58
+
59
+ /** Pi's extension entry point. Pi only passes `pi`; deps is a test seam. */
60
+ export default function createExtension(pi: ExtensionAPI, deps: Deps = {}): void {
61
+ const resolve =
62
+ deps.resolve ??
63
+ (async (request: DaemonRequest): Promise<DaemonReply> => {
64
+ const sockPath = process.env.ZOOID_DAEMON_SOCK
65
+ if (!sockPath) throw new Error('ZOOID_DAEMON_SOCK is not set')
66
+ try {
67
+ return { ok: true, result: await callDaemon(sockPath, request) }
68
+ } catch (cause) {
69
+ return { ok: false, error: cause instanceof Error ? cause.message : String(cause) }
70
+ }
71
+ })
72
+
73
+ const call = async (
74
+ method: DaemonRequest['method'],
75
+ params: Record<string, unknown>,
76
+ ctx: ExtensionContext,
77
+ ): Promise<ToolResult> => {
78
+ const acpSessionId = ctx.sessionManager?.getSessionId?.()
79
+ if (!acpSessionId) return error('zooid: this Pi session has no id; cannot address a Zooid session.')
80
+ try {
81
+ const reply = await resolve({ acpSessionId, method, params })
82
+ if (!reply.ok) {
83
+ return error(
84
+ `zooid: this Pi session (${acpSessionId}) is not bound to a Zooid session — ${reply.error}.`,
85
+ )
86
+ }
87
+ return { content: [{ type: 'text', text: JSON.stringify(reply.result) }] }
88
+ } catch (cause) {
89
+ const message = cause instanceof Error ? cause.message : String(cause)
90
+ return error(`zooid: could not reach the Zooid daemon (${message}).`)
91
+ }
92
+ }
93
+ const page = (params: Record<string, unknown>) => ({
94
+ limit: Math.min(Number(params.limit) || DEFAULT_LIMIT, MAX_LIMIT),
95
+ ...(typeof params.before === 'string' ? { before: params.before } : {}),
96
+ })
97
+ const registerRead = (
98
+ name: string,
99
+ label: string,
100
+ description: string,
101
+ method: DaemonRequest['method'],
102
+ parameters: unknown = pageParameters,
103
+ map: (params: Record<string, unknown>) => Record<string, unknown> = page,
104
+ ) => pi.registerTool({ name, label, description, parameters, execute: (_id, params, _signal, _update, ctx) => call(method, map(params), ctx) })
105
+
106
+ registerRead('zooid_get_history', 'Read room history', 'Read every message in the current room chronologically. Supports limit and before pagination.', 'getRoomHistory')
107
+ registerRead('zooid_get_recent_threads', 'Read recent threads', 'Scan the current room for top-level messages and thread roots, newest first.', 'getRecentThreads')
108
+ registerRead('zooid_get_thread_history', 'Read thread', 'Read a thread root and all its replies in chronological order.', 'getThreadHistory', {
109
+ ...pageParameters,
110
+ properties: { ...pageParameters.properties, thread_id: { type: 'string' } },
111
+ required: ['thread_id'],
112
+ }, (params) => ({ threadId: params.thread_id, ...page(params) }))
113
+ registerRead('zooid_get_members', 'List room members', 'List the humans and agents in the current room.', 'getChannelMembers', noParameters, () => ({}))
114
+ registerRead('zooid_get_room_info', 'Get room information', 'Describe the current room: id, display name, and transport kind.', 'getRoomInfo', noParameters, () => ({}))
115
+ registerRead('zooid_get_rooms', 'List rooms', 'List the rooms this agent is a member of. Valid targets for zooid_send_message.', 'getRooms', noParameters, () => ({}))
116
+
117
+ pi.registerTool({
118
+ name: 'zooid_send_message',
119
+ label: 'Send Zooid message',
120
+ description: 'Post a message into a room or thread this agent is bound to. Fire-and-forget: no assignee, no completion tracking, no notify. Use zooid_start_task_threads instead when the intent is delegation.',
121
+ parameters: {
122
+ type: 'object',
123
+ properties: {
124
+ room: { type: 'string' },
125
+ thread_id: { type: 'string' },
126
+ text: { type: 'string' },
127
+ },
128
+ required: ['room', 'text'],
129
+ },
130
+ async execute(_id, params, _signal, _update, ctx) {
131
+ return call('sendMessage', { room: params.room, thread_id: params.thread_id, text: params.text }, ctx)
132
+ },
133
+ })
134
+
135
+ pi.registerTool({
136
+ name: 'zooid_start_task_threads',
137
+ label: 'Start Zooid task threads',
138
+ description: 'Assign concurrent work to other agents in this room. Each task opens a separate thread. A delegated task cannot start tasks of its own. The return payload states how the result comes back — read `delivery` before deciding what to do next.',
139
+ promptGuidelines: [
140
+ 'After calling zooid_start_task_threads, read the `delivery` field of its result and end your turn now when it tells you to — do not poll the task thread while waiting for a result.',
141
+ ],
142
+ parameters: {
143
+ type: 'object',
144
+ properties: {
145
+ tasks: { type: 'array', minItems: 1, items: { type: 'object', properties: { agent: { type: 'string' }, prompt: { type: 'string' } }, required: ['agent', 'prompt'] } },
146
+ notify: { enum: ['caller', 'none'] },
147
+ },
148
+ required: ['tasks'],
149
+ },
150
+ async execute(_id, params, _signal, _update, ctx) {
151
+ return call('startTasks', { tasks: params.tasks, notify: params.notify ?? 'caller' }, ctx)
152
+ },
153
+ })
154
+ pi.registerTool({
155
+ name: 'zooid_complete_task',
156
+ label: 'Complete Zooid task',
157
+ description: 'Record an explicit result for the delegated task you were assigned.',
158
+ parameters: { type: 'object', properties: { summary: { type: 'string', minLength: 1 } }, required: ['summary'] },
159
+ async execute(_id, params, _signal, _update, ctx) {
160
+ if (typeof params.summary !== 'string' || !params.summary.trim()) return error('zooid: summary must not be empty.')
161
+ return call('completeTask', { summary: params.summary }, ctx)
162
+ },
163
+ })
164
+
165
+ // Role-conditional gating ([[ZOD084]]). Fail open on any error — the read
166
+ // tools and zooid_start_task_threads/zooid_complete_task are already
167
+ // registered above, so the failure mode of gating is removing a
168
+ // capability rather than declining to add one. A daemon hiccup must not
169
+ // strip an assignee's ability to finish its task.
170
+ pi.on('session_start', async (_evt, ctx) => {
171
+ const acpSessionId = ctx.sessionManager?.getSessionId?.()
172
+ if (!acpSessionId) return
173
+ let reply: DaemonReply
174
+ try {
175
+ reply = await resolve({ acpSessionId, method: 'describeRole', params: {} })
176
+ } catch {
177
+ return
178
+ }
179
+ if (!reply.ok) return
180
+ const role = reply.result as { is_task_assignee: boolean; can_start_task_threads: boolean }
181
+ const next = new Set(pi.getAllTools().map((t) => t.name))
182
+ if (role.is_task_assignee) next.add('zooid_complete_task')
183
+ else next.delete('zooid_complete_task')
184
+ if (role.can_start_task_threads) next.add('zooid_start_task_threads')
185
+ else next.delete('zooid_start_task_threads')
186
+ pi.setActiveTools([...next])
187
+ })
188
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { createRequire } from 'node:module'
2
+
3
+ /** Location of the self-contained extension installed by the Zooid daemon. */
4
+ export function resolvePiExtensionBundle(): string {
5
+ return createRequire(import.meta.url).resolve('@zooid/pi-extension/bundle')
6
+ }