@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/src/extension.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Api,
|
|
3
|
+
type AssistantMessage,
|
|
4
|
+
type AssistantMessageEventStream,
|
|
5
|
+
type Context,
|
|
6
|
+
createAssistantMessageEventStream,
|
|
7
|
+
type Model,
|
|
8
|
+
} from "@earendil-works/pi-ai";
|
|
9
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import {
|
|
11
|
+
collectTurnFiles,
|
|
12
|
+
DIRECT_MODE_ATTACHMENT_ERROR,
|
|
13
|
+
registerAttachCommand,
|
|
14
|
+
uploadTurnFiles,
|
|
15
|
+
userTextOf,
|
|
16
|
+
} from "./attachments";
|
|
17
|
+
import { RemoteAgentClient } from "./client";
|
|
18
|
+
import { registerWorkspaceCommands } from "./commands";
|
|
19
|
+
import { ControlPlaneClient } from "./control-plane";
|
|
20
|
+
import { replayHistory } from "./history";
|
|
21
|
+
import { registerConversationRenderers } from "./renderers";
|
|
22
|
+
import { emitRemoteResponse } from "./response-stream";
|
|
23
|
+
import { relayTarget, TargetRef, targetFromEnvironment } from "./session-target";
|
|
24
|
+
import { STATUS_KEY, StatusPoller } from "./status";
|
|
25
|
+
|
|
26
|
+
const PROVIDER = "pocketcoder-agentapi";
|
|
27
|
+
const MODEL = "remote-agent";
|
|
28
|
+
|
|
29
|
+
function emptyUsage(): AssistantMessage["usage"] {
|
|
30
|
+
return {
|
|
31
|
+
input: 0,
|
|
32
|
+
output: 0,
|
|
33
|
+
cacheRead: 0,
|
|
34
|
+
cacheWrite: 0,
|
|
35
|
+
totalTokens: 0,
|
|
36
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function remoteStream(
|
|
41
|
+
targets: TargetRef,
|
|
42
|
+
controlPlane: ControlPlaneClient | undefined,
|
|
43
|
+
attachmentQueue: string[],
|
|
44
|
+
model: Model<Api>,
|
|
45
|
+
context: Context,
|
|
46
|
+
signal?: AbortSignal,
|
|
47
|
+
onOutputStart?: () => void,
|
|
48
|
+
): AssistantMessageEventStream {
|
|
49
|
+
const stream = createAssistantMessageEventStream();
|
|
50
|
+
const output: AssistantMessage = {
|
|
51
|
+
role: "assistant",
|
|
52
|
+
content: [],
|
|
53
|
+
api: model.api,
|
|
54
|
+
provider: model.provider,
|
|
55
|
+
model: model.id,
|
|
56
|
+
usage: emptyUsage(),
|
|
57
|
+
stopReason: "pending",
|
|
58
|
+
timestamp: Date.now(),
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
void (async () => {
|
|
62
|
+
stream.push({ type: "start", partial: output });
|
|
63
|
+
try {
|
|
64
|
+
const target = targets.current;
|
|
65
|
+
if (target.mode === "unset") {
|
|
66
|
+
throw new Error("no workspace attached; run /workspace or /workspace-create first");
|
|
67
|
+
}
|
|
68
|
+
const files = collectTurnFiles(context, attachmentQueue);
|
|
69
|
+
if (files.length > 0 && (target.mode !== "relay" || !controlPlane)) {
|
|
70
|
+
throw new Error(DIRECT_MODE_ATTACHMENT_ERROR);
|
|
71
|
+
}
|
|
72
|
+
let attachmentIds: string[] = [];
|
|
73
|
+
if (files.length > 0 && target.mode === "relay" && controlPlane) {
|
|
74
|
+
attachmentIds = await uploadTurnFiles(controlPlane, target.workspaceId, files);
|
|
75
|
+
attachmentQueue.length = 0;
|
|
76
|
+
}
|
|
77
|
+
const client = new RemoteAgentClient({ serviceUrl: target.serviceUrl, key: target.key });
|
|
78
|
+
await emitRemoteResponse(
|
|
79
|
+
stream,
|
|
80
|
+
output,
|
|
81
|
+
async (onSnapshot) =>
|
|
82
|
+
await client.send(userTextOf(context), signal, attachmentIds, onSnapshot),
|
|
83
|
+
onOutputStart,
|
|
84
|
+
);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
output.stopReason = signal?.aborted ? "aborted" : "error";
|
|
87
|
+
output.errorMessage = error instanceof Error ? error.message : String(error);
|
|
88
|
+
stream.push({ type: "error", reason: output.stopReason, error: output });
|
|
89
|
+
} finally {
|
|
90
|
+
stream.end();
|
|
91
|
+
}
|
|
92
|
+
})();
|
|
93
|
+
|
|
94
|
+
return stream;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function pickInitialWorkspace(
|
|
98
|
+
controlPlane: ControlPlaneClient,
|
|
99
|
+
targets: TargetRef,
|
|
100
|
+
context: ExtensionContext,
|
|
101
|
+
): Promise<void> {
|
|
102
|
+
const target = targets.current;
|
|
103
|
+
if (target.mode !== "unset" || !context.hasUI) return;
|
|
104
|
+
const workspaces = (await controlPlane.workspaces.list({ state: "ready" })).items;
|
|
105
|
+
if (workspaces.length === 0) {
|
|
106
|
+
context.ui.notify("no ready workspaces; run /workspace-create", "warning");
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const labels = workspaces.map(
|
|
110
|
+
(workspace) =>
|
|
111
|
+
`${workspace.external_id} · ${workspace.template.name} · ${workspace.id.slice(0, 8)}`,
|
|
112
|
+
);
|
|
113
|
+
const selection = await context.ui.select("Attach to workspace", labels);
|
|
114
|
+
if (selection === undefined) {
|
|
115
|
+
context.ui.notify("no workspace attached; run /workspace to attach", "warning");
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const workspace = workspaces[labels.indexOf(selection)];
|
|
119
|
+
if (workspace) targets.set(relayTarget(target.baseUrl, target.key, workspace.id));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export default function (pi: ExtensionAPI): void {
|
|
123
|
+
const targets = new TargetRef(targetFromEnvironment());
|
|
124
|
+
const initial = targets.current;
|
|
125
|
+
const controlPlane =
|
|
126
|
+
initial.mode === "direct"
|
|
127
|
+
? undefined
|
|
128
|
+
: new ControlPlaneClient({ baseUrl: initial.baseUrl, key: initial.key });
|
|
129
|
+
const attachmentQueue: string[] = [];
|
|
130
|
+
let poller: StatusPoller | undefined;
|
|
131
|
+
let activeContext: ExtensionContext | undefined;
|
|
132
|
+
|
|
133
|
+
pi.registerProvider(PROVIDER, {
|
|
134
|
+
name: "PocketCoder remote agent",
|
|
135
|
+
baseUrl: initial.mode === "direct" ? initial.serviceUrl : initial.baseUrl,
|
|
136
|
+
apiKey: "local-ui",
|
|
137
|
+
api: "openai-completions",
|
|
138
|
+
models: [
|
|
139
|
+
{
|
|
140
|
+
id: MODEL,
|
|
141
|
+
name: "PocketCoder remote agent",
|
|
142
|
+
reasoning: false,
|
|
143
|
+
input: ["text", "image"],
|
|
144
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
145
|
+
contextWindow: 1_000_000,
|
|
146
|
+
maxTokens: 100_000,
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
streamSimple: (model, context, options) =>
|
|
150
|
+
remoteStream(targets, controlPlane, attachmentQueue, model, context, options?.signal, () =>
|
|
151
|
+
activeContext?.ui.setWorkingVisible(false),
|
|
152
|
+
),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
registerConversationRenderers(pi);
|
|
156
|
+
registerAttachCommand(pi, { targets, queue: attachmentQueue });
|
|
157
|
+
if (controlPlane) registerWorkspaceCommands(pi, { targets, controlPlane });
|
|
158
|
+
|
|
159
|
+
pi.on("session_start", async (_event, context) => {
|
|
160
|
+
activeContext = context;
|
|
161
|
+
pi.setActiveTools([]);
|
|
162
|
+
await poller?.stop();
|
|
163
|
+
poller = undefined;
|
|
164
|
+
|
|
165
|
+
if (controlPlane) await pickInitialWorkspace(controlPlane, targets, context);
|
|
166
|
+
const target = targets.current;
|
|
167
|
+
if (target.mode === "direct") {
|
|
168
|
+
context.ui.setStatus(STATUS_KEY, "direct agentapi");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (target.mode === "unset") {
|
|
172
|
+
context.ui.setStatus(STATUS_KEY, "no workspace");
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
context.ui.setStatus(STATUS_KEY, `ws ${target.workspaceId.slice(0, 8)}`);
|
|
176
|
+
if (!controlPlane) return;
|
|
177
|
+
try {
|
|
178
|
+
const workspace = await controlPlane.workspaces.get(target.workspaceId);
|
|
179
|
+
await replayHistory(pi, controlPlane, target.workspaceId);
|
|
180
|
+
poller = new StatusPoller(controlPlane, workspace, context.ui);
|
|
181
|
+
poller.start();
|
|
182
|
+
} catch (error) {
|
|
183
|
+
context.ui.notify(
|
|
184
|
+
`could not load workspace history: ${error instanceof Error ? error.message : String(error)}`,
|
|
185
|
+
"warning",
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
pi.on("turn_start", async () => poller?.pause());
|
|
191
|
+
pi.on("turn_end", async (_event, context) => {
|
|
192
|
+
context.ui.setWorkingVisible(true);
|
|
193
|
+
poller?.resume();
|
|
194
|
+
});
|
|
195
|
+
pi.on("session_shutdown", async () => {
|
|
196
|
+
await poller?.stop();
|
|
197
|
+
poller = undefined;
|
|
198
|
+
activeContext = undefined;
|
|
199
|
+
});
|
|
200
|
+
}
|
package/src/history.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { ControlPlaneClient } from "./control-plane";
|
|
2
|
+
import { ConversationGoneError } from "./control-plane";
|
|
3
|
+
import {
|
|
4
|
+
type ConversationEntryData,
|
|
5
|
+
HISTORY_ENTRY_TYPE,
|
|
6
|
+
type HistoryNoticeData,
|
|
7
|
+
NOTICE_ENTRY_TYPE,
|
|
8
|
+
} from "./renderers";
|
|
9
|
+
|
|
10
|
+
export interface ReplayOptions {
|
|
11
|
+
pageLimit?: number;
|
|
12
|
+
maxMessages?: number;
|
|
13
|
+
maxPages?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ReplayOutcome {
|
|
17
|
+
replayed: number;
|
|
18
|
+
total: number;
|
|
19
|
+
truncated: boolean;
|
|
20
|
+
gone: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface EntrySink {
|
|
24
|
+
appendEntry<T = unknown>(customType: string, data?: T): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Replays the workspace's durable conversation into the local session as
|
|
29
|
+
* custom entries. The server transcript is the source of truth: this runs on
|
|
30
|
+
* every attach, and entries never participate in LLM context.
|
|
31
|
+
*/
|
|
32
|
+
interface TranscriptTail {
|
|
33
|
+
tail: ConversationEntryData[];
|
|
34
|
+
total: number;
|
|
35
|
+
pages: number;
|
|
36
|
+
exhaustedPages: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function collectTranscriptTail(
|
|
40
|
+
controlPlane: ControlPlaneClient,
|
|
41
|
+
workspaceId: string,
|
|
42
|
+
options: Required<ReplayOptions>,
|
|
43
|
+
): Promise<TranscriptTail> {
|
|
44
|
+
const tail: ConversationEntryData[] = [];
|
|
45
|
+
let total = 0;
|
|
46
|
+
let pages = 0;
|
|
47
|
+
let cursor: string | undefined;
|
|
48
|
+
while (pages < options.maxPages) {
|
|
49
|
+
const page = await controlPlane.conversations.list(workspaceId, {
|
|
50
|
+
cursor,
|
|
51
|
+
limit: options.pageLimit,
|
|
52
|
+
});
|
|
53
|
+
pages += 1;
|
|
54
|
+
for (const message of page.items) {
|
|
55
|
+
total += 1;
|
|
56
|
+
tail.push({
|
|
57
|
+
role: message.role,
|
|
58
|
+
content: message.content,
|
|
59
|
+
seq: message.seq,
|
|
60
|
+
occurred_at: message.occurred_at,
|
|
61
|
+
kind: message.metadata?.kind,
|
|
62
|
+
metadata: message.metadata,
|
|
63
|
+
});
|
|
64
|
+
if (tail.length > options.maxMessages) tail.shift();
|
|
65
|
+
}
|
|
66
|
+
if (page.nextCursor === null) return { tail, total, pages, exhaustedPages: false };
|
|
67
|
+
cursor = page.nextCursor;
|
|
68
|
+
}
|
|
69
|
+
return { tail, total, pages, exhaustedPages: true };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function replayHistory(
|
|
73
|
+
pi: EntrySink,
|
|
74
|
+
controlPlane: ControlPlaneClient,
|
|
75
|
+
workspaceId: string,
|
|
76
|
+
options: ReplayOptions = {},
|
|
77
|
+
): Promise<ReplayOutcome> {
|
|
78
|
+
let transcript: TranscriptTail;
|
|
79
|
+
try {
|
|
80
|
+
transcript = await collectTranscriptTail(controlPlane, workspaceId, {
|
|
81
|
+
pageLimit: options.pageLimit ?? 200,
|
|
82
|
+
maxMessages: options.maxMessages ?? 1000,
|
|
83
|
+
maxPages: options.maxPages ?? 50,
|
|
84
|
+
});
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (error instanceof ConversationGoneError) {
|
|
87
|
+
pi.appendEntry<HistoryNoticeData>(NOTICE_ENTRY_TYPE, {
|
|
88
|
+
text:
|
|
89
|
+
error.code === "conversation.deleted"
|
|
90
|
+
? "conversation history was deleted"
|
|
91
|
+
: "conversation history has expired",
|
|
92
|
+
level: "warning",
|
|
93
|
+
});
|
|
94
|
+
return { replayed: 0, total: 0, truncated: false, gone: true };
|
|
95
|
+
}
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const { tail, total, pages, exhaustedPages } = transcript;
|
|
100
|
+
const truncated = total > tail.length || exhaustedPages;
|
|
101
|
+
if (truncated) {
|
|
102
|
+
pi.appendEntry<HistoryNoticeData>(NOTICE_ENTRY_TYPE, {
|
|
103
|
+
text: exhaustedPages
|
|
104
|
+
? `history partially replayed (stopped after ${pages} pages)`
|
|
105
|
+
: `showing last ${tail.length} of ${total} messages`,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
for (const message of tail) {
|
|
109
|
+
pi.appendEntry<ConversationEntryData>(HISTORY_ENTRY_TYPE, message);
|
|
110
|
+
}
|
|
111
|
+
return { replayed: tail.length, total, truncated, gone: false };
|
|
112
|
+
}
|
package/src/launch.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve, sep } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
export interface PiInvocation {
|
|
6
|
+
command: string;
|
|
7
|
+
args: string[];
|
|
8
|
+
env: NodeJS.ProcessEnv;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ResolveOptions {
|
|
12
|
+
env?: NodeJS.ProcessEnv;
|
|
13
|
+
argv?: string[];
|
|
14
|
+
execPath?: string;
|
|
15
|
+
resolvePath?: (specifier: string) => string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const PI_FLAGS = [
|
|
19
|
+
"--provider",
|
|
20
|
+
"pocketcoder-agentapi",
|
|
21
|
+
"--model",
|
|
22
|
+
"remote-agent",
|
|
23
|
+
"--api-key",
|
|
24
|
+
"local-ui",
|
|
25
|
+
"--no-tools",
|
|
26
|
+
"--no-extensions",
|
|
27
|
+
"--no-skills",
|
|
28
|
+
"--no-context-files",
|
|
29
|
+
"--no-prompt-templates",
|
|
30
|
+
"--no-session",
|
|
31
|
+
"--offline",
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
function piBinPath(resolvePath: (specifier: string) => string): string {
|
|
35
|
+
// The package's exports map hides ./package.json, so locate the package
|
|
36
|
+
// root from its resolved main entry instead.
|
|
37
|
+
const entry = resolvePath("@earendil-works/pi-coding-agent");
|
|
38
|
+
const marker = `${sep}pi-coding-agent${sep}`;
|
|
39
|
+
const index = entry.lastIndexOf(marker);
|
|
40
|
+
if (index === -1) {
|
|
41
|
+
throw new Error(`could not locate @earendil-works/pi-coding-agent from ${entry}`);
|
|
42
|
+
}
|
|
43
|
+
const packageRoot = entry.slice(0, index + marker.length - 1);
|
|
44
|
+
const manifest = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8")) as {
|
|
45
|
+
bin?: Record<string, string>;
|
|
46
|
+
};
|
|
47
|
+
const bin = manifest.bin?.pi;
|
|
48
|
+
if (!bin) throw new Error("@earendil-works/pi-coding-agent does not declare a pi bin");
|
|
49
|
+
return resolve(packageRoot, bin);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function extensionPath(): string {
|
|
53
|
+
// Works from both src/bin.ts (dev) and dist/bin.js (published): the
|
|
54
|
+
// package root is one directory up in both layouts.
|
|
55
|
+
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
56
|
+
return resolve(packageRoot, "src/extension.ts");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function resolvePiInvocation(options: ResolveOptions = {}): PiInvocation {
|
|
60
|
+
const env = options.env ?? process.env;
|
|
61
|
+
if (!env.POCKETCODER_KEY) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
"POCKETCODER_KEY is required. Issue a machine key with `pcd keys issue` and export it, along with POCKETCODER_URL for your PocketCoder server.",
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
// The pi package's exports map only defines the "import" condition, so
|
|
67
|
+
// resolution must go through ESM resolve, not createRequire.
|
|
68
|
+
const resolvePath =
|
|
69
|
+
options.resolvePath ?? ((specifier: string) => fileURLToPath(import.meta.resolve(specifier)));
|
|
70
|
+
const childEnv: NodeJS.ProcessEnv = { ...env };
|
|
71
|
+
// Pi must never talk to a model provider directly; every turn goes
|
|
72
|
+
// through the PocketCoder relay.
|
|
73
|
+
delete childEnv.OPENAI_API_KEY;
|
|
74
|
+
return {
|
|
75
|
+
command: options.execPath ?? process.execPath,
|
|
76
|
+
args: [
|
|
77
|
+
piBinPath(resolvePath),
|
|
78
|
+
...PI_FLAGS,
|
|
79
|
+
"--extension",
|
|
80
|
+
extensionPath(),
|
|
81
|
+
...(options.argv ?? []),
|
|
82
|
+
],
|
|
83
|
+
env: childEnv,
|
|
84
|
+
};
|
|
85
|
+
}
|
package/src/renderers.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { ExtensionAPI, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import { splitAttachmentManifest } from "@pstdio/pocketcoder-client";
|
|
4
|
+
|
|
5
|
+
export const HISTORY_ENTRY_TYPE = "pocketcoder-conversation";
|
|
6
|
+
export const NOTICE_ENTRY_TYPE = "pocketcoder-history-notice";
|
|
7
|
+
|
|
8
|
+
export interface ConversationEntryData {
|
|
9
|
+
role: string;
|
|
10
|
+
content: string;
|
|
11
|
+
seq: number;
|
|
12
|
+
occurred_at: string;
|
|
13
|
+
/** Reserved for future typed payloads (tool_use, permission_request, ...). */
|
|
14
|
+
kind?: string;
|
|
15
|
+
metadata?: Record<string, string>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface HistoryNoticeData {
|
|
19
|
+
text: string;
|
|
20
|
+
level?: "info" | "warning";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The slice of Pi's Theme the formatters use; Theme satisfies it structurally. */
|
|
24
|
+
export interface ThemeLike {
|
|
25
|
+
fg(color: ThemeColor, text: string): string;
|
|
26
|
+
bold(text: string): string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function roleHeader(data: ConversationEntryData, theme: ThemeLike): string {
|
|
30
|
+
const time = data.occurred_at.replace("T", " ").replace(/\.\d+Z?$|Z$/, "");
|
|
31
|
+
return theme.fg("muted", `${data.role} · ${time}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// User messages may end in the generated attachment manifest; history shows
|
|
35
|
+
// the attachment names instead of the raw markup and managed paths.
|
|
36
|
+
function userBody(content: string, theme: ThemeLike): string {
|
|
37
|
+
const { text, attachments } = splitAttachmentManifest(content);
|
|
38
|
+
const body = theme.fg("userMessageText", text);
|
|
39
|
+
if (!attachments) return body;
|
|
40
|
+
const list = attachments
|
|
41
|
+
.map((attachment) => theme.fg("muted", `⌁ ${attachment.name} (${attachment.size_bytes} bytes)`))
|
|
42
|
+
.join("\n");
|
|
43
|
+
return `${body}\n${list}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function formatConversationMessage(data: ConversationEntryData, theme: ThemeLike): string {
|
|
47
|
+
switch (data.kind ?? "text") {
|
|
48
|
+
// Future typed kinds (tool_use, permission_request, ...) become new cases;
|
|
49
|
+
// unknown kinds deliberately fall through to the text rendering.
|
|
50
|
+
default: {
|
|
51
|
+
const header = roleHeader(data, theme);
|
|
52
|
+
const body =
|
|
53
|
+
data.role === "user"
|
|
54
|
+
? userBody(data.content, theme)
|
|
55
|
+
: data.role === "assistant"
|
|
56
|
+
? data.content
|
|
57
|
+
: theme.fg("dim", data.content);
|
|
58
|
+
return `${header}\n${body}`;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function formatHistoryNotice(data: HistoryNoticeData, theme: ThemeLike): string {
|
|
64
|
+
return theme.fg(data.level === "warning" ? "warning" : "muted", data.text);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function registerConversationRenderers(pi: ExtensionAPI): void {
|
|
68
|
+
pi.registerEntryRenderer<ConversationEntryData>(HISTORY_ENTRY_TYPE, (entry, _options, theme) => {
|
|
69
|
+
if (!entry.data) return undefined;
|
|
70
|
+
return new Text(formatConversationMessage(entry.data, theme), 1, 0);
|
|
71
|
+
});
|
|
72
|
+
pi.registerEntryRenderer<HistoryNoticeData>(NOTICE_ENTRY_TYPE, (entry, _options, theme) => {
|
|
73
|
+
if (!entry.data) return undefined;
|
|
74
|
+
return new Text(formatHistoryNotice(entry.data, theme), 1, 0);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { AssistantMessage, AssistantMessageEventStream } from "@earendil-works/pi-ai";
|
|
2
|
+
|
|
3
|
+
type AssistantEvent = Parameters<AssistantMessageEventStream["push"]>[0];
|
|
4
|
+
|
|
5
|
+
export interface AssistantEventSink {
|
|
6
|
+
push(event: AssistantEvent): void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function textOf(message: AssistantMessage): string {
|
|
10
|
+
const content = message.content[0];
|
|
11
|
+
return content?.type === "text" ? content.text : "";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function emitRemoteResponse(
|
|
15
|
+
stream: AssistantEventSink,
|
|
16
|
+
output: AssistantMessage,
|
|
17
|
+
send: (onSnapshot: (snapshot: string) => void) => Promise<string>,
|
|
18
|
+
onOutputStart: () => void = () => {},
|
|
19
|
+
): Promise<void> {
|
|
20
|
+
let previous = "";
|
|
21
|
+
let started = false;
|
|
22
|
+
const update = (snapshot: string) => {
|
|
23
|
+
if (snapshot === previous) return;
|
|
24
|
+
const delta = snapshot.startsWith(previous) ? snapshot.slice(previous.length) : "";
|
|
25
|
+
if (!started) {
|
|
26
|
+
output.content.push({ type: "text", text: "" });
|
|
27
|
+
started = true;
|
|
28
|
+
onOutputStart();
|
|
29
|
+
stream.push({ type: "text_start", contentIndex: 0, partial: output });
|
|
30
|
+
}
|
|
31
|
+
const content = output.content[0];
|
|
32
|
+
if (content?.type === "text") content.text = snapshot;
|
|
33
|
+
previous = snapshot;
|
|
34
|
+
stream.push({ type: "text_delta", contentIndex: 0, delta, partial: output });
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const final = await send(update);
|
|
38
|
+
update(final);
|
|
39
|
+
if (!started) update("");
|
|
40
|
+
stream.push({ type: "text_end", contentIndex: 0, content: textOf(output), partial: output });
|
|
41
|
+
output.stopReason = "stop";
|
|
42
|
+
stream.push({ type: "done", reason: "stop", message: output });
|
|
43
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export interface RelayTarget {
|
|
2
|
+
mode: "relay";
|
|
3
|
+
baseUrl: string;
|
|
4
|
+
key: string;
|
|
5
|
+
workspaceId: string;
|
|
6
|
+
serviceUrl: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface DirectTarget {
|
|
10
|
+
mode: "direct";
|
|
11
|
+
key: string;
|
|
12
|
+
serviceUrl: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface UnsetTarget {
|
|
16
|
+
mode: "unset";
|
|
17
|
+
baseUrl: string;
|
|
18
|
+
key: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type SessionTarget = RelayTarget | DirectTarget | UnsetTarget;
|
|
22
|
+
|
|
23
|
+
export function relayTarget(baseUrl: string, key: string, workspaceId: string): RelayTarget {
|
|
24
|
+
const base = baseUrl.replace(/\/$/, "");
|
|
25
|
+
return {
|
|
26
|
+
mode: "relay",
|
|
27
|
+
baseUrl: base,
|
|
28
|
+
key,
|
|
29
|
+
workspaceId,
|
|
30
|
+
serviceUrl: `${base}/v1/workspaces/${encodeURIComponent(workspaceId)}/agent`,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function targetFromEnvironment(env: NodeJS.ProcessEnv = process.env): SessionTarget {
|
|
35
|
+
const key = env.POCKETCODER_KEY;
|
|
36
|
+
if (!key) throw new Error("POCKETCODER_KEY is required");
|
|
37
|
+
const directUrl = env.POCKETCODER_AGENTAPI_URL;
|
|
38
|
+
if (directUrl) return { mode: "direct", key, serviceUrl: directUrl.replace(/\/$/, "") };
|
|
39
|
+
|
|
40
|
+
const baseUrl = (env.POCKETCODER_URL ?? "http://127.0.0.1:7080").replace(/\/$/, "");
|
|
41
|
+
const workspaceId = env.POCKETCODER_WORKSPACE_ID;
|
|
42
|
+
if (!workspaceId) return { mode: "unset", baseUrl, key };
|
|
43
|
+
return relayTarget(baseUrl, key, workspaceId);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class TargetRef {
|
|
47
|
+
private target: SessionTarget;
|
|
48
|
+
private readonly listeners: Array<(target: SessionTarget) => void> = [];
|
|
49
|
+
|
|
50
|
+
constructor(initial: SessionTarget) {
|
|
51
|
+
this.target = initial;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
get current(): SessionTarget {
|
|
55
|
+
return this.target;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
set(next: SessionTarget): void {
|
|
59
|
+
this.target = next;
|
|
60
|
+
for (const listener of this.listeners) listener(next);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
onChange(listener: (target: SessionTarget) => void): void {
|
|
64
|
+
this.listeners.push(listener);
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/status.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { ControlPlaneClient, WorkspaceSummary } from "./control-plane";
|
|
2
|
+
import { TERMINAL_WORKSPACE_STATES } from "./control-plane";
|
|
3
|
+
|
|
4
|
+
export const STATUS_KEY = "pocketcoder";
|
|
5
|
+
|
|
6
|
+
export interface StatusUi {
|
|
7
|
+
setStatus(key: string, text: string | undefined): void;
|
|
8
|
+
notify(message: string, type?: "info" | "warning" | "error"): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface StatusPollerOptions {
|
|
12
|
+
waitSeconds?: number;
|
|
13
|
+
delay?: (ms: number) => Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function statusText(
|
|
17
|
+
workspace: Pick<WorkspaceSummary, "id" | "state" | "agent_state">,
|
|
18
|
+
): string {
|
|
19
|
+
return `ws ${workspace.id.slice(0, 8)} · ${workspace.state}/${workspace.agent_state}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function sleep(ms: number): Promise<void> {
|
|
23
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Keeps the status bar in sync with the workspace via the durable change
|
|
28
|
+
* cursor. Paused during turns so RemoteAgentClient.send() is the only
|
|
29
|
+
* consumer of the change feed while the agent is working.
|
|
30
|
+
*/
|
|
31
|
+
export class StatusPoller {
|
|
32
|
+
private readonly controlPlane: ControlPlaneClient;
|
|
33
|
+
private readonly workspaceId: string;
|
|
34
|
+
private readonly ui: StatusUi;
|
|
35
|
+
private readonly waitSeconds: number;
|
|
36
|
+
private readonly delay: (ms: number) => Promise<void>;
|
|
37
|
+
private cursor: number;
|
|
38
|
+
private paused = false;
|
|
39
|
+
private stopped = false;
|
|
40
|
+
private wake: (() => void) | undefined;
|
|
41
|
+
private controller: AbortController | undefined;
|
|
42
|
+
private loop: Promise<void> | undefined;
|
|
43
|
+
private lastText: string | undefined;
|
|
44
|
+
|
|
45
|
+
constructor(
|
|
46
|
+
controlPlane: ControlPlaneClient,
|
|
47
|
+
workspace: Pick<WorkspaceSummary, "id" | "change_cursor">,
|
|
48
|
+
ui: StatusUi,
|
|
49
|
+
options: StatusPollerOptions = {},
|
|
50
|
+
) {
|
|
51
|
+
this.controlPlane = controlPlane;
|
|
52
|
+
this.workspaceId = workspace.id;
|
|
53
|
+
this.cursor = workspace.change_cursor ?? 0;
|
|
54
|
+
this.ui = ui;
|
|
55
|
+
this.waitSeconds = options.waitSeconds ?? 30;
|
|
56
|
+
this.delay = options.delay ?? sleep;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
start(): void {
|
|
60
|
+
if (!this.loop) this.loop = this.run();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
pause(): void {
|
|
64
|
+
this.paused = true;
|
|
65
|
+
this.controller?.abort();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
resume(): void {
|
|
69
|
+
if (!this.paused) return;
|
|
70
|
+
this.paused = false;
|
|
71
|
+
this.wake?.();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async stop(): Promise<void> {
|
|
75
|
+
this.stopped = true;
|
|
76
|
+
this.controller?.abort();
|
|
77
|
+
this.wake?.();
|
|
78
|
+
await this.loop;
|
|
79
|
+
this.ui.setStatus(STATUS_KEY, undefined);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private async run(): Promise<void> {
|
|
83
|
+
let backoffMs = 1_000;
|
|
84
|
+
while (!this.stopped) {
|
|
85
|
+
if (this.paused) {
|
|
86
|
+
await new Promise<void>((resolve) => {
|
|
87
|
+
this.wake = resolve;
|
|
88
|
+
});
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
this.controller = new AbortController();
|
|
92
|
+
try {
|
|
93
|
+
const change = await this.controlPlane.workspaces.change(
|
|
94
|
+
this.workspaceId,
|
|
95
|
+
this.cursor,
|
|
96
|
+
this.waitSeconds,
|
|
97
|
+
{ signal: this.controller.signal },
|
|
98
|
+
);
|
|
99
|
+
backoffMs = 1_000;
|
|
100
|
+
this.cursor = change.cursor;
|
|
101
|
+
this.lastText = statusText(change.workspace);
|
|
102
|
+
this.ui.setStatus(STATUS_KEY, this.lastText);
|
|
103
|
+
if (TERMINAL_WORKSPACE_STATES.has(change.workspace.state)) {
|
|
104
|
+
this.ui.notify(
|
|
105
|
+
`workspace ${change.workspace.id.slice(0, 8)} is ${change.workspace.state}`,
|
|
106
|
+
change.workspace.state === "succeeded" ? "info" : "warning",
|
|
107
|
+
);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
if (this.stopped || this.paused) continue;
|
|
112
|
+
this.ui.setStatus(
|
|
113
|
+
STATUS_KEY,
|
|
114
|
+
`${this.lastText ?? `ws ${this.workspaceId.slice(0, 8)}`} · reconnecting`,
|
|
115
|
+
);
|
|
116
|
+
await this.delay(backoffMs);
|
|
117
|
+
backoffMs = Math.min(backoffMs * 2, 30_000);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|