@pstdio/pocketcoder-remote 0.2.0

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pufflig AB
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,110 @@
1
+ # @pstdio/pocketcoder-remote
2
+
3
+ A terminal UI for PocketCoder workspaces, built on the [Pi coding agent](https://github.com/badlogic/pi-mono). The coding agent and all file operations stay inside the remote workspace; this package runs Pi locally as a thin client that sends turns through PocketCoder's service relay and replays the workspace's durable conversation history.
4
+
5
+ ```text
6
+ local Pi TUI → PocketCoder service relay → AgentAPI → remote coding agent
7
+ ```
8
+
9
+ AgentAPI message snapshots stream into Pi while a turn is running. Each
10
+ snapshot replaces Pi's mutable partial message, so terminal rewrites render
11
+ without duplicated text; the completed message still comes from AgentAPI's
12
+ stable history. Older servers and workspace snapshots automatically use the
13
+ final-response polling path.
14
+
15
+ ## Install
16
+
17
+ ```sh
18
+ npm install -g @pstdio/pocketcoder-remote # or: bun add -g @pstdio/pocketcoder-remote
19
+ ```
20
+
21
+ Or run without installing: `npx @pstdio/pocketcoder-remote`.
22
+
23
+ ## Usage
24
+
25
+ ```sh
26
+ POCKETCODER_URL=http://127.0.0.1:7080 \
27
+ POCKETCODER_KEY=pkt_... \
28
+ POCKETCODER_WORKSPACE_ID=<uuid> \
29
+ pocketcoder-remote [initial prompt]
30
+ ```
31
+
32
+ `POCKETCODER_WORKSPACE_ID` is optional — without it, a workspace picker opens on startup.
33
+
34
+ Environment variables:
35
+
36
+ | Variable | Required | Purpose |
37
+ |---|---|---|
38
+ | `POCKETCODER_URL` | yes* | PocketCoder server base URL |
39
+ | `POCKETCODER_KEY` | yes | Machine key (`pkt_...`) |
40
+ | `POCKETCODER_WORKSPACE_ID` | no | Workspace to attach to; omit to pick interactively |
41
+ | `POCKETCODER_AGENTAPI_URL` | no | Direct AgentAPI URL (bypasses the relay; disables history, picker, and status features) |
42
+
43
+ *Not required when `POCKETCODER_AGENTAPI_URL` is set.
44
+
45
+ The machine key needs scopes `workspaces:read`, `services:relay`, and
46
+ `conversations:read`. File attachments additionally require `attachments:write`.
47
+ The in-UI create and cancel commands use `templates:read`, `workspaces:create`,
48
+ and `workspaces:cancel`; they degrade gracefully when the key lacks them.
49
+
50
+ ## Using your own Pi install
51
+
52
+ The `pocketcoder-remote` launcher is a thin wrapper: it spawns the Pi version
53
+ pinned by this package with the extension and the thin-client flags below. If
54
+ you already use Pi, you can load the extension directly instead:
55
+
56
+ ```sh
57
+ POCKETCODER_URL=... POCKETCODER_KEY=... POCKETCODER_WORKSPACE_ID=... \
58
+ pi --extension node_modules/@pstdio/pocketcoder-remote/src/extension.ts \
59
+ --provider pocketcoder-agentapi --model remote-agent --api-key local-ui \
60
+ --no-tools --no-extensions --no-skills --no-context-files \
61
+ --no-prompt-templates --no-session --offline
62
+ ```
63
+
64
+ The flags are part of the contract, not decoration:
65
+
66
+ - `--no-session` keeps Pi from persisting a local transcript. History replay
67
+ appends the server transcript on every attach, so a persisted local session
68
+ would duplicate it on resume; the durable server conversation is the only
69
+ source of truth.
70
+ - `--no-tools` (with the extension also clearing active tools) ensures the
71
+ local Pi never reads or edits local files — the remote agent owns all
72
+ workspace operations.
73
+ - `--provider pocketcoder-agentapi --model remote-agent` routes every turn
74
+ through the PocketCoder relay; `--offline` and the remaining `--no-*` flags
75
+ keep local skills, context files, and other extensions out of a session that
76
+ a remote agent is actually driving.
77
+
78
+ Do not install the extension into `~/.pi/agent/extensions/` for everyday use:
79
+ it registers a remote provider and expects the flags above, so loading it into
80
+ a normal local coding session is not supported. This package pins
81
+ `@earendil-works/pi-coding-agent` to an exact version; running the extension
82
+ under a different Pi version is untested.
83
+
84
+ ## In-UI commands
85
+
86
+ - `/workspace` — pick and switch to another ready workspace (replays its history)
87
+ - `/workspace-create` — pick a template, create a workspace, wait for ready, switch to it
88
+ - `/workspace-cancel` — cancel the current workspace (with confirmation)
89
+ - `/attach <path>` — queue a local file to upload with the next message
90
+
91
+ ## File attachments
92
+
93
+ Three gestures turn local files into workspace files, all uploaded through
94
+ the PocketCoder attachment API when the turn is sent:
95
+
96
+ - paste or drop an image (stored as `pasted-image.<ext>`),
97
+ - mention a file as `@./report.pdf` (or `@"my report.pdf"` for spaces),
98
+ - queue one explicitly with `/attach <path>`.
99
+
100
+ The agent receives each file's workspace path under `$HOME/.pcd/attachments`.
101
+ If an upload fails the message is not sent. With a direct AgentAPI URL
102
+ (`POCKETCODER_AGENTAPI_URL`) managed uploads are unavailable — attachment
103
+ gestures fail with an explanation while plain text keeps working.
104
+
105
+ ## Behavior notes
106
+
107
+ - The server's durable conversation is the source of truth: every attach replays history from `GET /v1/workspaces/{id}/conversation`. Pi's local session persistence is disabled.
108
+ - Live text uses AgentAPI's `GET /events` stream when the workspace snapshot and protocol support it. Intermediate snapshots are never persisted.
109
+ - Local Pi coding tools are disabled; the remote agent does all the work.
110
+ - The status bar shows the workspace id, workspace state, and agent state, updated via the durable change cursor.
package/dist/bin.js ADDED
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/bin.ts
4
+ import { spawn } from "node:child_process";
5
+
6
+ // src/launch.ts
7
+ import { readFileSync } from "node:fs";
8
+ import { dirname, resolve, sep } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ var PI_FLAGS = [
11
+ "--provider",
12
+ "pocketcoder-agentapi",
13
+ "--model",
14
+ "remote-agent",
15
+ "--api-key",
16
+ "local-ui",
17
+ "--no-tools",
18
+ "--no-extensions",
19
+ "--no-skills",
20
+ "--no-context-files",
21
+ "--no-prompt-templates",
22
+ "--no-session",
23
+ "--offline"
24
+ ];
25
+ function piBinPath(resolvePath) {
26
+ const entry = resolvePath("@earendil-works/pi-coding-agent");
27
+ const marker = `${sep}pi-coding-agent${sep}`;
28
+ const index = entry.lastIndexOf(marker);
29
+ if (index === -1) {
30
+ throw new Error(`could not locate @earendil-works/pi-coding-agent from ${entry}`);
31
+ }
32
+ const packageRoot = entry.slice(0, index + marker.length - 1);
33
+ const manifest = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8"));
34
+ const bin = manifest.bin?.pi;
35
+ if (!bin)
36
+ throw new Error("@earendil-works/pi-coding-agent does not declare a pi bin");
37
+ return resolve(packageRoot, bin);
38
+ }
39
+ function extensionPath() {
40
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
41
+ return resolve(packageRoot, "src/extension.ts");
42
+ }
43
+ function resolvePiInvocation(options = {}) {
44
+ const env = options.env ?? process.env;
45
+ if (!env.POCKETCODER_KEY) {
46
+ throw new Error("POCKETCODER_KEY is required. Issue a machine key with `pcd keys issue` and export it, along with POCKETCODER_URL for your PocketCoder server.");
47
+ }
48
+ const resolvePath = options.resolvePath ?? ((specifier) => fileURLToPath(import.meta.resolve(specifier)));
49
+ const childEnv = { ...env };
50
+ delete childEnv.OPENAI_API_KEY;
51
+ return {
52
+ command: options.execPath ?? process.execPath,
53
+ args: [
54
+ piBinPath(resolvePath),
55
+ ...PI_FLAGS,
56
+ "--extension",
57
+ extensionPath(),
58
+ ...options.argv ?? []
59
+ ],
60
+ env: childEnv
61
+ };
62
+ }
63
+
64
+ // src/bin.ts
65
+ function main() {
66
+ let invocation;
67
+ try {
68
+ invocation = resolvePiInvocation({ argv: process.argv.slice(2) });
69
+ } catch (error) {
70
+ console.error(`pocketcoder-remote: ${error instanceof Error ? error.message : String(error)}`);
71
+ process.exit(1);
72
+ }
73
+ const child = spawn(invocation.command, invocation.args, {
74
+ stdio: "inherit",
75
+ env: invocation.env
76
+ });
77
+ child.on("error", (error) => {
78
+ console.error(`pocketcoder-remote: failed to launch pi: ${error.message}`);
79
+ process.exit(1);
80
+ });
81
+ child.on("exit", (code, signal) => {
82
+ process.exit(code ?? (signal ? 1 : 0));
83
+ });
84
+ }
85
+ main();
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@pstdio/pocketcoder-remote",
3
+ "nx": {
4
+ "tags": [
5
+ "scope:remote",
6
+ "type:app"
7
+ ]
8
+ },
9
+ "version": "0.2.0",
10
+ "private": false,
11
+ "description": "Pi-based terminal UI for PocketCoder workspaces.",
12
+ "type": "module",
13
+ "license": "MIT",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/pufflyai/pocketcoder.git",
17
+ "directory": "packages/remote"
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "src",
22
+ "!src/**/*.test.ts",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "bin": {
27
+ "pocketcoder-remote": "dist/bin.js"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "engines": {
33
+ "node": ">=22.19.0"
34
+ },
35
+ "scripts": {
36
+ "build": "bun build ./src/bin.ts --outdir ./dist --target node",
37
+ "start": "bun ./src/bin.ts",
38
+ "test": "bun test",
39
+ "typecheck": "tsc --project ./tsconfig.json --noEmit"
40
+ },
41
+ "dependencies": {
42
+ "@pstdio/pocketcoder-client": "workspace:*",
43
+ "@earendil-works/pi-ai": "0.83.0",
44
+ "@earendil-works/pi-coding-agent": "0.83.0",
45
+ "@earendil-works/pi-tui": "0.83.0"
46
+ },
47
+ "devDependencies": {
48
+ "@types/bun": "1.3.14",
49
+ "typescript": "5.9.3"
50
+ }
51
+ }
@@ -0,0 +1,104 @@
1
+ interface MessageUpdate {
2
+ event: "message_update";
3
+ data: {
4
+ id: number;
5
+ message: string;
6
+ role: string;
7
+ time?: string;
8
+ };
9
+ }
10
+
11
+ interface StatusChange {
12
+ event: "status_change";
13
+ data: {
14
+ agent_type?: string;
15
+ status: "running" | "stable";
16
+ };
17
+ }
18
+
19
+ interface AgentError {
20
+ event: "agent_error";
21
+ data: {
22
+ level?: string;
23
+ message: string;
24
+ time?: string;
25
+ };
26
+ }
27
+
28
+ export type AgentApiEvent = MessageUpdate | StatusChange | AgentError;
29
+
30
+ function isRecord(value: unknown): value is Record<string, unknown> {
31
+ return typeof value === "object" && value !== null;
32
+ }
33
+
34
+ function eventPayload(block: string): { event: string; parsed: Record<string, unknown> } | null {
35
+ let event = "message";
36
+ const data: string[] = [];
37
+ for (const line of block.split("\n")) {
38
+ if (line.startsWith(":")) continue;
39
+ const separator = line.indexOf(":");
40
+ const field = separator === -1 ? line : line.slice(0, separator);
41
+ const value = separator === -1 ? "" : line.slice(separator + 1).replace(/^ /, "");
42
+ if (field === "event") event = value;
43
+ if (field === "data") data.push(value);
44
+ }
45
+ if (data.length === 0) return null;
46
+ try {
47
+ const parsed = JSON.parse(data.join("\n")) as unknown;
48
+ return isRecord(parsed) ? { event, parsed } : null;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ function parseEvent(block: string): AgentApiEvent | null {
55
+ const payload = eventPayload(block);
56
+ if (!payload) return null;
57
+ const { event, parsed } = payload;
58
+ if (
59
+ event === "message_update" &&
60
+ typeof parsed.id === "number" &&
61
+ typeof parsed.message === "string" &&
62
+ typeof parsed.role === "string"
63
+ ) {
64
+ return { event, data: parsed as MessageUpdate["data"] };
65
+ }
66
+ if (event === "status_change" && (parsed.status === "running" || parsed.status === "stable")) {
67
+ return { event, data: parsed as StatusChange["data"] };
68
+ }
69
+ if (event === "agent_error" && typeof parsed.message === "string") {
70
+ return { event, data: parsed as AgentError["data"] };
71
+ }
72
+ return null;
73
+ }
74
+
75
+ export async function* readAgentApiEvents(
76
+ body: ReadableStream<Uint8Array>,
77
+ signal: AbortSignal,
78
+ ): AsyncGenerator<AgentApiEvent> {
79
+ const reader = body.getReader();
80
+ const decoder = new TextDecoder();
81
+ let buffer = "";
82
+ const abort = () => {
83
+ void reader.cancel(signal.reason).catch(() => {});
84
+ };
85
+ if (signal.aborted) abort();
86
+ else signal.addEventListener("abort", abort, { once: true });
87
+ try {
88
+ while (!signal.aborted) {
89
+ const next = await reader.read();
90
+ if (next.done) break;
91
+ buffer = `${buffer}${decoder.decode(next.value, { stream: true })}`.replaceAll("\r\n", "\n");
92
+ let boundary = buffer.indexOf("\n\n");
93
+ while (boundary >= 0) {
94
+ const event = parseEvent(buffer.slice(0, boundary));
95
+ buffer = buffer.slice(boundary + 2);
96
+ if (event) yield event;
97
+ boundary = buffer.indexOf("\n\n");
98
+ }
99
+ }
100
+ } finally {
101
+ signal.removeEventListener("abort", abort);
102
+ reader.releaseLock();
103
+ }
104
+ }
@@ -0,0 +1,158 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import { basename, extname, isAbsolute, resolve } from "node:path";
3
+ import type { Context } from "@earendil-works/pi-ai";
4
+ import type { CommandContext, CommandRegistrar } from "./commands";
5
+ import type { ControlPlaneClient } from "./control-plane";
6
+ import type { TargetRef } from "./session-target";
7
+
8
+ // Turn-level attachment capture for the Pi remote: pasted images, explicit
9
+ // @path tokens, and the /attach queue all upload through the PocketCoder
10
+ // attachment API before the message referencing them is sent.
11
+
12
+ export const DIRECT_MODE_ATTACHMENT_ERROR =
13
+ "file attachments need the PocketCoder workspace API; a direct AgentAPI URL (POCKETCODER_AGENTAPI_URL) cannot accept managed uploads";
14
+
15
+ export interface TurnFile {
16
+ name: string;
17
+ mediaType: string;
18
+ bytes: Uint8Array;
19
+ localPath?: string;
20
+ }
21
+
22
+ const IMAGE_EXTENSIONS: Record<string, string> = {
23
+ "image/gif": "gif",
24
+ "image/jpeg": "jpg",
25
+ "image/png": "png",
26
+ "image/svg+xml": "svg",
27
+ "image/webp": "webp",
28
+ };
29
+
30
+ const MEDIA_TYPES: Record<string, string> = {
31
+ ".csv": "text/csv",
32
+ ".gif": "image/gif",
33
+ ".html": "text/html",
34
+ ".jpeg": "image/jpeg",
35
+ ".jpg": "image/jpeg",
36
+ ".json": "application/json",
37
+ ".md": "text/markdown",
38
+ ".pdf": "application/pdf",
39
+ ".png": "image/png",
40
+ ".svg": "image/svg+xml",
41
+ ".txt": "text/plain",
42
+ ".webp": "image/webp",
43
+ };
44
+
45
+ function lastUserContent(context: Context): Context["messages"][number]["content"] | undefined {
46
+ const message = context.messages.findLast((candidate) => candidate.role === "user");
47
+ return message?.role === "user" ? message.content : undefined;
48
+ }
49
+
50
+ export function userTextOf(context: Context): string {
51
+ const content = lastUserContent(context);
52
+ if (content === undefined) throw new Error("local Pi did not provide a user message");
53
+ if (typeof content === "string") return content;
54
+ return content
55
+ .filter((part) => part.type === "text")
56
+ .map((part) => part.text)
57
+ .join("\n");
58
+ }
59
+
60
+ function imageParts(context: Context): TurnFile[] {
61
+ const content = lastUserContent(context);
62
+ if (content === undefined || typeof content === "string") return [];
63
+ return content
64
+ .filter((part) => part.type === "image")
65
+ .map((part) => ({
66
+ name: `pasted-image.${IMAGE_EXTENSIONS[part.mimeType] ?? "bin"}`,
67
+ mediaType: part.mimeType,
68
+ bytes: Uint8Array.from(Buffer.from(part.data, "base64")),
69
+ }));
70
+ }
71
+
72
+ // Explicit `@path` tokens that resolve to a local regular file; quoted forms
73
+ // (`@"my file.pdf"`) support spaces.
74
+ export function pathTokens(text: string, cwd = process.cwd()): string[] {
75
+ const paths: string[] = [];
76
+ for (const match of text.matchAll(/@(?:"([^"]+)"|(\S+))/g)) {
77
+ const token = (match[1] ?? match[2]) as string;
78
+ const path = isAbsolute(token) ? token : resolve(cwd, token);
79
+ if (isFile(path) && !paths.includes(path)) paths.push(path);
80
+ }
81
+ return paths;
82
+ }
83
+
84
+ function isFile(path: string): boolean {
85
+ try {
86
+ return statSync(path).isFile();
87
+ } catch {
88
+ return false;
89
+ }
90
+ }
91
+
92
+ function fileFromPath(path: string): TurnFile {
93
+ return {
94
+ name: basename(path),
95
+ mediaType: MEDIA_TYPES[extname(path).toLowerCase()] ?? "application/octet-stream",
96
+ bytes: new Uint8Array(readFileSync(path)),
97
+ localPath: path,
98
+ };
99
+ }
100
+
101
+ export function collectTurnFiles(context: Context, queue: string[], cwd?: string): TurnFile[] {
102
+ const text = userTextOf(context);
103
+ const paths = [...queue];
104
+ for (const path of pathTokens(text, cwd)) {
105
+ if (!paths.includes(path)) paths.push(path);
106
+ }
107
+ return [...imageParts(context), ...paths.map(fileFromPath)];
108
+ }
109
+
110
+ export async function uploadTurnFiles(
111
+ controlPlane: ControlPlaneClient,
112
+ workspaceId: string,
113
+ files: TurnFile[],
114
+ ): Promise<string[]> {
115
+ const ids: string[] = [];
116
+ for (const file of files) {
117
+ const uploaded = await controlPlane.attachments.upload(workspaceId, {
118
+ name: file.name,
119
+ mediaType: file.mediaType,
120
+ body: file.bytes,
121
+ sizeBytes: file.bytes.byteLength,
122
+ });
123
+ ids.push(uploaded.id);
124
+ }
125
+ return ids;
126
+ }
127
+
128
+ export interface AttachCommandDeps {
129
+ targets: TargetRef;
130
+ queue: string[];
131
+ }
132
+
133
+ export function registerAttachCommand(pi: CommandRegistrar, deps: AttachCommandDeps): void {
134
+ pi.registerCommand("attach", {
135
+ description: "Queue a local file to upload with the next message",
136
+ handler: async (args, ctx: CommandContext) => {
137
+ if (deps.targets.current.mode === "direct") {
138
+ ctx.ui.notify(DIRECT_MODE_ATTACHMENT_ERROR, "warning");
139
+ return;
140
+ }
141
+ const token = args.trim();
142
+ if (!token) {
143
+ ctx.ui.notify("usage: /attach <path>", "info");
144
+ return;
145
+ }
146
+ const path = isAbsolute(token) ? token : resolve(process.cwd(), token);
147
+ if (!isFile(path)) {
148
+ ctx.ui.notify(`no such file: ${token}`, "warning");
149
+ return;
150
+ }
151
+ deps.queue.push(path);
152
+ ctx.ui.notify(
153
+ `queued ${basename(path)} (${deps.queue.length} attachment${deps.queue.length === 1 ? "" : "s"})`,
154
+ "info",
155
+ );
156
+ },
157
+ });
158
+ }
package/src/bin.ts ADDED
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { resolvePiInvocation } from "./launch";
4
+
5
+ function main(): void {
6
+ let invocation: ReturnType<typeof resolvePiInvocation>;
7
+ try {
8
+ invocation = resolvePiInvocation({ argv: process.argv.slice(2) });
9
+ } catch (error) {
10
+ console.error(`pocketcoder-remote: ${error instanceof Error ? error.message : String(error)}`);
11
+ process.exit(1);
12
+ }
13
+ const child = spawn(invocation.command, invocation.args, {
14
+ stdio: "inherit",
15
+ env: invocation.env,
16
+ });
17
+ child.on("error", (error) => {
18
+ console.error(`pocketcoder-remote: failed to launch pi: ${error.message}`);
19
+ process.exit(1);
20
+ });
21
+ child.on("exit", (code, signal) => {
22
+ process.exit(code ?? (signal ? 1 : 0));
23
+ });
24
+ }
25
+
26
+ main();