@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 +21 -0
- package/README.md +110 -0
- package/dist/bin.js +85 -0
- package/package.json +51 -0
- package/src/agentapi-events.ts +104 -0
- package/src/attachments.ts +158 -0
- package/src/bin.ts +26 -0
- package/src/client.ts +344 -0
- package/src/commands.ts +149 -0
- package/src/control-plane.ts +30 -0
- package/src/environment.ts +19 -0
- package/src/extension.ts +200 -0
- package/src/history.ts +112 -0
- package/src/launch.ts +85 -0
- package/src/renderers.ts +76 -0
- package/src/response-stream.ts +43 -0
- package/src/session-target.ts +66 -0
- package/src/status.ts +121 -0
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();
|