@plannotator/artifact-server-pi 0.1.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/LICENSE +21 -0
- package/README.md +71 -0
- package/index.ts +273 -0
- package/package.json +42 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 backnotprop
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# @plannotator/artifact-server-pi
|
|
2
|
+
|
|
3
|
+
The Artifact Server bridge for the [Pi coding agent](https://pi.dev). It
|
|
4
|
+
connects a live Pi session to an Artifact Server installation so that
|
|
5
|
+
annotation bundles sent from the review UI arrive in Pi as follow-up work,
|
|
6
|
+
and Pi replies to and resolves each comment thread through the
|
|
7
|
+
`artifact_comments` tool.
|
|
8
|
+
|
|
9
|
+
## What it does
|
|
10
|
+
|
|
11
|
+
- Registers this Pi session as an agent (`POST /api/v1/agents`), self-named
|
|
12
|
+
after the working directory. Restarts, `/new`, and `/resume` reclaim the
|
|
13
|
+
same agent identity, so pending bundles survive.
|
|
14
|
+
- Long-polls the dispatch mailbox (`POST /api/v1/agents/:id/claims?wait=25`).
|
|
15
|
+
Each claimed bundle is rendered as one message and injected with
|
|
16
|
+
`pi.sendUserMessage(text, {deliverAs: "followUp"})` — always follow-up
|
|
17
|
+
delivery, never steering: Pi finishes its current work first, then receives
|
|
18
|
+
exactly one bundle per work boundary.
|
|
19
|
+
- Holds delivery while the session is compacting, and reports `delivered`
|
|
20
|
+
only after Pi accepted the message.
|
|
21
|
+
- Registers the `artifact_comments` tool with `get_bundle`, `reply`, and
|
|
22
|
+
`resolve` operations wrapping the comment HTTP routes with the same
|
|
23
|
+
credential, so the agent can close the loop without any human action.
|
|
24
|
+
- Fails open. Without configuration it stays dormant after one notice. With
|
|
25
|
+
the server unreachable it backs off between 1 s and 30 s and Pi continues
|
|
26
|
+
normally. No bridge failure is ever thrown into Pi.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pi install npm:@plannotator/artifact-server-pi
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
or in `settings.json`:
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
{
|
|
38
|
+
"packages": ["npm:@plannotator/artifact-server-pi"]
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
For development inside this repository:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pi -e integrations/pi/index.ts
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Configuration
|
|
49
|
+
|
|
50
|
+
Resolved once per session start, in order:
|
|
51
|
+
|
|
52
|
+
| Source | Setting | Meaning |
|
|
53
|
+
| --- | --- | --- |
|
|
54
|
+
| Environment | `ARTIFACT_SERVER_ORIGIN` | Server origin, e.g. `https://artifacts.example.com`. Used together with the token below. |
|
|
55
|
+
| Environment | `ARTIFACT_SERVER_AGENT_TOKEN` | Bearer credential. Needs `agent:connect` plus comment read/write for the tool; the local API token carries everything. |
|
|
56
|
+
| Environment | `ARTIFACT_SERVER_AGENT_NAME` | Optional display-name override (default: the working directory's basename). |
|
|
57
|
+
| Local discovery | `~/.artifact-server/local-service.json` | The managed local server's discovery record (loopback origin). |
|
|
58
|
+
| Local discovery | `~/.artifact-server/local-api-token` | The local installation's private API credential. |
|
|
59
|
+
|
|
60
|
+
If neither source resolves, the extension notifies once and stays dormant for
|
|
61
|
+
the session. It never blocks a Pi event handler on the network.
|
|
62
|
+
|
|
63
|
+
## Compatibility
|
|
64
|
+
|
|
65
|
+
- Pi extension API: tested against `@earendil-works/pi-coding-agent` 0.84.x.
|
|
66
|
+
The bridge fails soft on missing API surface (dormant plus one notice,
|
|
67
|
+
never a crash).
|
|
68
|
+
- The package version tracks Artifact Server releases; it is a client of the
|
|
69
|
+
server's dispatch API (`project/spec/agent-dispatch-spec.md`).
|
|
70
|
+
- Ships TypeScript source; Pi loads extensions through jiti with no build
|
|
71
|
+
step.
|
package/index.ts
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Artifact Server bridge — the thin Pi-facing entry.
|
|
3
|
+
*
|
|
4
|
+
* Registers this Pi session with an Artifact Server installation, receives
|
|
5
|
+
* annotation bundles as follow-up work through the claim loop in
|
|
6
|
+
* `@plannotator/agent-bridge`, and registers the `artifact_comments` tool the
|
|
7
|
+
* agent uses to reply to and resolve each thread. All logic lives in the
|
|
8
|
+
* shared core; this file only wires it to the live extension API.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {homedir, hostname} from "node:os";
|
|
12
|
+
|
|
13
|
+
import {Type} from "typebox";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
ActivityBeacon,
|
|
17
|
+
type BridgeHandle,
|
|
18
|
+
type BridgeNoticeKind,
|
|
19
|
+
chooseDisplayName,
|
|
20
|
+
type CommentOperations,
|
|
21
|
+
createCommentOperations,
|
|
22
|
+
type EnvironmentConfiguration,
|
|
23
|
+
type FollowUpDelivery,
|
|
24
|
+
type HostPort,
|
|
25
|
+
resolveBridgeCredentials,
|
|
26
|
+
startBridge,
|
|
27
|
+
ThreadLocationCache,
|
|
28
|
+
} from "@plannotator/agent-bridge";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A compaction flag older than this is treated as expired: a cancelled
|
|
32
|
+
* compaction never emits `session_compact`, so the hold must not stick.
|
|
33
|
+
*/
|
|
34
|
+
const compactionFlagLifetimeMilliseconds = 5 * 60 * 1_000;
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// The narrow, structurally-typed slice of Pi's extension API this entry uses.
|
|
38
|
+
// Typing it locally keeps the package free of a hard dependency on Pi's own
|
|
39
|
+
// type package while the real API remains structurally compatible.
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
interface PiNotifier {
|
|
43
|
+
notify(message: string, kind?: BridgeNoticeKind): void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface PiSessionManagerLike {
|
|
47
|
+
getSessionId(): string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface PiExtensionContextLike {
|
|
51
|
+
cwd: string;
|
|
52
|
+
sessionManager: PiSessionManagerLike;
|
|
53
|
+
ui: PiNotifier;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface PiSessionStartEventLike {
|
|
57
|
+
reason: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface PiToolTextContent {
|
|
61
|
+
text: string;
|
|
62
|
+
type: "text";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface ArtifactCommentsDetails {
|
|
66
|
+
operation: string;
|
|
67
|
+
threadIds: readonly string[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface PiToolResultLike {
|
|
71
|
+
content: PiToolTextContent[];
|
|
72
|
+
details: ArtifactCommentsDetails;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface ArtifactCommentsParams {
|
|
76
|
+
body?: string;
|
|
77
|
+
operation: "get_bundle" | "reply" | "resolve";
|
|
78
|
+
threadId?: string;
|
|
79
|
+
threadIds?: string[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface PiToolDefinitionLike {
|
|
83
|
+
description: string;
|
|
84
|
+
execute(
|
|
85
|
+
toolCallId: string,
|
|
86
|
+
params: ArtifactCommentsParams,
|
|
87
|
+
signal: AbortSignal | undefined,
|
|
88
|
+
): Promise<PiToolResultLike>;
|
|
89
|
+
label: string;
|
|
90
|
+
name: string;
|
|
91
|
+
parameters: ReturnType<typeof artifactCommentsParameters>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface PiExtensionApi {
|
|
95
|
+
on(
|
|
96
|
+
event: "session_start",
|
|
97
|
+
handler: (
|
|
98
|
+
event: PiSessionStartEventLike,
|
|
99
|
+
ctx: PiExtensionContextLike,
|
|
100
|
+
) => Promise<void>,
|
|
101
|
+
): void;
|
|
102
|
+
on(event: "session_before_compact", handler: () => void): void;
|
|
103
|
+
on(event: "session_compact", handler: () => void): void;
|
|
104
|
+
on(
|
|
105
|
+
event: "session_shutdown",
|
|
106
|
+
handler: (event?: PiSessionShutdownEventLike) => Promise<void>,
|
|
107
|
+
): void;
|
|
108
|
+
registerTool(tool: PiToolDefinitionLike): void;
|
|
109
|
+
sendUserMessage(text: string, delivery: FollowUpDelivery): void;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function artifactCommentsParameters() {
|
|
113
|
+
return Type.Object({
|
|
114
|
+
body: Type.Optional(Type.String({
|
|
115
|
+
description: "Reply text (reply operation only).",
|
|
116
|
+
})),
|
|
117
|
+
operation: Type.Union([
|
|
118
|
+
Type.Literal("get_bundle"),
|
|
119
|
+
Type.Literal("reply"),
|
|
120
|
+
Type.Literal("resolve"),
|
|
121
|
+
], {
|
|
122
|
+
description:
|
|
123
|
+
"get_bundle reads threads with their replies; reply posts one reply; " +
|
|
124
|
+
"resolve closes one thread.",
|
|
125
|
+
}),
|
|
126
|
+
threadId: Type.Optional(Type.String({
|
|
127
|
+
description: "Target thread id (reply and resolve operations).",
|
|
128
|
+
})),
|
|
129
|
+
threadIds: Type.Optional(Type.Array(Type.String(), {
|
|
130
|
+
description: "Thread ids to read (get_bundle operation).",
|
|
131
|
+
})),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function environmentConfiguration(): EnvironmentConfiguration {
|
|
136
|
+
return {
|
|
137
|
+
agentDisplayName: process.env["ARTIFACT_SERVER_AGENT_NAME"],
|
|
138
|
+
agentToken: process.env["ARTIFACT_SERVER_AGENT_TOKEN"],
|
|
139
|
+
origin: process.env["ARTIFACT_SERVER_ORIGIN"],
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function textResult(
|
|
144
|
+
operation: string,
|
|
145
|
+
threadIds: readonly string[],
|
|
146
|
+
text: string,
|
|
147
|
+
): PiToolResultLike {
|
|
148
|
+
return {
|
|
149
|
+
content: [{text, type: "text"}],
|
|
150
|
+
details: {operation, threadIds},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The shutdown reasons Pi emits; only a real quit is a departure. */
|
|
155
|
+
interface PiSessionShutdownEventLike {
|
|
156
|
+
readonly reason?: string;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Artifact Server bridge extension factory. */
|
|
160
|
+
export default function artifactServerBridge(pi: PiExtensionApi): void {
|
|
161
|
+
let bridge: BridgeHandle | null = null;
|
|
162
|
+
let comments: CommentOperations | null = null;
|
|
163
|
+
let compactionStartedAt: number | null = null;
|
|
164
|
+
const locations = new ThreadLocationCache();
|
|
165
|
+
|
|
166
|
+
const stopBridge = async (disconnect: boolean): Promise<void> => {
|
|
167
|
+
const active = bridge;
|
|
168
|
+
bridge = null;
|
|
169
|
+
if (active !== null) await active.stop({disconnect});
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// Compaction is tracked from events because the extension context exposes
|
|
173
|
+
// no probe; the timestamp bounds the flag so a cancelled compaction (which
|
|
174
|
+
// never emits session_compact) cannot hold deliveries forever.
|
|
175
|
+
pi.on("session_before_compact", () => {
|
|
176
|
+
compactionStartedAt = Date.now();
|
|
177
|
+
});
|
|
178
|
+
pi.on("session_compact", () => {
|
|
179
|
+
compactionStartedAt = null;
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
183
|
+
await stopBridge(false);
|
|
184
|
+
const environment = environmentConfiguration();
|
|
185
|
+
const credentials = await resolveBridgeCredentials(environment, homedir());
|
|
186
|
+
// One beacon per session: replies and resolves through the tool count
|
|
187
|
+
// against the bundles this session's bridge delivered.
|
|
188
|
+
const beacon = new ActivityBeacon();
|
|
189
|
+
comments = credentials === null
|
|
190
|
+
? null
|
|
191
|
+
: createCommentOperations(credentials, fetch, locations, beacon);
|
|
192
|
+
|
|
193
|
+
const port: HostPort = {
|
|
194
|
+
isCompacting: () =>
|
|
195
|
+
compactionStartedAt !== null &&
|
|
196
|
+
Date.now() - compactionStartedAt < compactionFlagLifetimeMilliseconds,
|
|
197
|
+
notify: (message, kind) => {
|
|
198
|
+
ctx.ui.notify(message, kind);
|
|
199
|
+
},
|
|
200
|
+
sendUserMessage: (text, delivery) => {
|
|
201
|
+
// Always named follow-up delivery: safe while idle (delivery mode is
|
|
202
|
+
// ignored and a run starts) and queued to the work boundary while
|
|
203
|
+
// streaming. Never "steer", and never omitted.
|
|
204
|
+
pi.sendUserMessage(text, delivery);
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
bridge = startBridge({
|
|
209
|
+
agentSessionId: ctx.sessionManager.getSessionId(),
|
|
210
|
+
beacon,
|
|
211
|
+
credentials,
|
|
212
|
+
displayName: chooseDisplayName(environment, ctx.cwd),
|
|
213
|
+
fetchImplementation: fetch,
|
|
214
|
+
host: port,
|
|
215
|
+
hostname: hostname(),
|
|
216
|
+
kind: "pi",
|
|
217
|
+
locations,
|
|
218
|
+
workingDirectory: ctx.cwd,
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
pi.on("session_shutdown", async (event) => {
|
|
223
|
+
await stopBridge(event?.reason === "quit");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
pi.registerTool({
|
|
227
|
+
description:
|
|
228
|
+
"Read, reply to, and resolve Artifact Server comment threads that were " +
|
|
229
|
+
"sent to this agent. Use get_bundle to read threads, reply to record " +
|
|
230
|
+
"what you did on a thread, and resolve to close it when done.",
|
|
231
|
+
async execute(_toolCallId, params) {
|
|
232
|
+
const operations = comments;
|
|
233
|
+
if (operations === null) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
"Artifact Server is not configured; the bridge is dormant.",
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
if (params.operation === "get_bundle") {
|
|
239
|
+
const threadIds = params.threadIds ?? [];
|
|
240
|
+
if (threadIds.length === 0) {
|
|
241
|
+
throw new Error("get_bundle requires threadIds.");
|
|
242
|
+
}
|
|
243
|
+
const details = [];
|
|
244
|
+
for (const threadId of threadIds) {
|
|
245
|
+
// eslint-disable-next-line no-await-in-loop
|
|
246
|
+
details.push(await operations.getThread(threadId));
|
|
247
|
+
}
|
|
248
|
+
return textResult(
|
|
249
|
+
"get_bundle",
|
|
250
|
+
threadIds,
|
|
251
|
+
JSON.stringify(details, null, 2),
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
const threadId = params.threadId ?? "";
|
|
255
|
+
if (threadId === "") {
|
|
256
|
+
throw new Error(`${params.operation} requires threadId.`);
|
|
257
|
+
}
|
|
258
|
+
if (params.operation === "reply") {
|
|
259
|
+
const body = params.body ?? "";
|
|
260
|
+
if (body.trim() === "") {
|
|
261
|
+
throw new Error("reply requires a non-empty body.");
|
|
262
|
+
}
|
|
263
|
+
await operations.reply(threadId, body);
|
|
264
|
+
return textResult("reply", [threadId], `Replied to ${threadId}.`);
|
|
265
|
+
}
|
|
266
|
+
await operations.resolve(threadId);
|
|
267
|
+
return textResult("resolve", [threadId], `Resolved ${threadId}.`);
|
|
268
|
+
},
|
|
269
|
+
label: "Artifact comments",
|
|
270
|
+
name: "artifact_comments",
|
|
271
|
+
parameters: artifactCommentsParameters(),
|
|
272
|
+
});
|
|
273
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@plannotator/artifact-server-pi",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Pi coding-agent bridge for Artifact Server: receives annotation bundles as follow-up work and closes them through the comment API.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package",
|
|
8
|
+
"artifact-server"
|
|
9
|
+
],
|
|
10
|
+
"pi": {
|
|
11
|
+
"extensions": [
|
|
12
|
+
"./index.ts"
|
|
13
|
+
]
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"index.ts",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@plannotator/agent-bridge": "^0.1.1"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"typebox": "^1.3.7"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"typebox": "1.3.7",
|
|
28
|
+
"typescript": "7.0.2"
|
|
29
|
+
},
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"repository": {
|
|
35
|
+
"directory": "integrations/pi",
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/plannotator/artifact-server.git"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
41
|
+
}
|
|
42
|
+
}
|