@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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +71 -0
  3. package/index.ts +273 -0
  4. 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
+ }