@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/client.ts
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { type AgentApiEvent, readAgentApiEvents } from "./agentapi-events";
|
|
2
|
+
|
|
3
|
+
export interface AgentApiMessage {
|
|
4
|
+
id: number;
|
|
5
|
+
content: string;
|
|
6
|
+
role: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface RemoteAgentClientConfig {
|
|
10
|
+
serviceUrl: string;
|
|
11
|
+
key: string;
|
|
12
|
+
pollIntervalMs?: number;
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface AgentApiStatus {
|
|
17
|
+
status?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface AgentApiMessages {
|
|
21
|
+
messages?: unknown;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface WorkspaceChange {
|
|
25
|
+
cursor?: unknown;
|
|
26
|
+
workspace?: {
|
|
27
|
+
agent_state?: unknown;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type FetchLike = typeof fetch;
|
|
32
|
+
type SnapshotCallback = (snapshot: string) => void;
|
|
33
|
+
|
|
34
|
+
function delay(ms: number, signal?: AbortSignal): Promise<void> {
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
const onAbort = () => {
|
|
37
|
+
clearTimeout(timeout);
|
|
38
|
+
reject(signal?.reason ?? new Error("remote request aborted"));
|
|
39
|
+
};
|
|
40
|
+
const timeout = setTimeout(() => {
|
|
41
|
+
signal?.removeEventListener("abort", onAbort);
|
|
42
|
+
resolve();
|
|
43
|
+
}, ms);
|
|
44
|
+
if (signal?.aborted) {
|
|
45
|
+
onAbort();
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
53
|
+
return typeof value === "object" && value !== null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseMessages(value: unknown): AgentApiMessage[] {
|
|
57
|
+
const items = isRecord(value) && Array.isArray(value.messages) ? value.messages : [];
|
|
58
|
+
return items.flatMap((item) => {
|
|
59
|
+
if (
|
|
60
|
+
!isRecord(item) ||
|
|
61
|
+
typeof item.id !== "number" ||
|
|
62
|
+
typeof item.content !== "string" ||
|
|
63
|
+
typeof item.role !== "string"
|
|
64
|
+
) {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
return [{ id: item.id, content: item.content, role: item.role }];
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isAgentMessage(message: AgentApiMessage): boolean {
|
|
72
|
+
return message.role === "agent" || message.role === "assistant";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function changesUrlFor(serviceUrl: string): string | undefined {
|
|
76
|
+
const url = new URL(serviceUrl);
|
|
77
|
+
const match = url.pathname.match(/^(.*\/v1\/workspaces\/[^/]+)\/(?:agent|services\/agent)$/);
|
|
78
|
+
if (!match) return undefined;
|
|
79
|
+
url.pathname = `${match[1]}/changes`;
|
|
80
|
+
url.search = "";
|
|
81
|
+
url.hash = "";
|
|
82
|
+
return url.toString();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function responseError(response: Response): Promise<string> {
|
|
86
|
+
const body = (await response.text()).trim();
|
|
87
|
+
return body ? `${response.status} ${body.slice(0, 1000)}` : String(response.status);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export class RemoteAgentClient {
|
|
91
|
+
readonly serviceUrl: string;
|
|
92
|
+
readonly key: string;
|
|
93
|
+
readonly pollIntervalMs: number;
|
|
94
|
+
readonly timeoutMs: number;
|
|
95
|
+
readonly fetchImpl: FetchLike;
|
|
96
|
+
private changesUrl: string | undefined;
|
|
97
|
+
|
|
98
|
+
constructor(config: RemoteAgentClientConfig, fetchImpl: FetchLike = fetch) {
|
|
99
|
+
this.serviceUrl = config.serviceUrl.replace(/\/$/, "");
|
|
100
|
+
this.key = config.key;
|
|
101
|
+
this.pollIntervalMs = config.pollIntervalMs ?? 250;
|
|
102
|
+
this.timeoutMs = config.timeoutMs ?? 600_000;
|
|
103
|
+
this.fetchImpl = fetchImpl;
|
|
104
|
+
this.changesUrl = changesUrlFor(this.serviceUrl);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private async request(path: string, init: RequestInit = {}): Promise<Response> {
|
|
108
|
+
return await this.fetchImpl(`${this.serviceUrl}${path}`, {
|
|
109
|
+
...init,
|
|
110
|
+
headers: {
|
|
111
|
+
authorization: `Bearer ${this.key}`,
|
|
112
|
+
...(init.body ? { "content-type": "application/json" } : {}),
|
|
113
|
+
...(init.headers ?? {}),
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private async messages(signal?: AbortSignal): Promise<AgentApiMessage[]> {
|
|
119
|
+
const response = await this.request("/messages", { signal });
|
|
120
|
+
if (!response.ok) {
|
|
121
|
+
throw new Error(`AgentAPI messages request failed: ${await responseError(response)}`);
|
|
122
|
+
}
|
|
123
|
+
return parseMessages((await response.json()) as AgentApiMessages);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private async status(signal?: AbortSignal): Promise<string> {
|
|
127
|
+
const response = await this.request("/status", { signal });
|
|
128
|
+
if (!response.ok) {
|
|
129
|
+
throw new Error(`AgentAPI status request failed: ${await responseError(response)}`);
|
|
130
|
+
}
|
|
131
|
+
const body = (await response.json()) as AgentApiStatus;
|
|
132
|
+
if (body.status !== "running" && body.status !== "stable") {
|
|
133
|
+
throw new Error(`AgentAPI returned an unknown status: ${JSON.stringify(body.status)}`);
|
|
134
|
+
}
|
|
135
|
+
return body.status;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private async eventStream(signal: AbortSignal): Promise<Response | null> {
|
|
139
|
+
const response = await this.request("/events", {
|
|
140
|
+
headers: { accept: "text/event-stream" },
|
|
141
|
+
signal,
|
|
142
|
+
});
|
|
143
|
+
if ([404, 409, 422].includes(response.status)) return null;
|
|
144
|
+
if (!response.ok) {
|
|
145
|
+
throw new Error(`AgentAPI events request failed: ${await responseError(response)}`);
|
|
146
|
+
}
|
|
147
|
+
if (!response.body) throw new Error("AgentAPI events response had no body");
|
|
148
|
+
return response;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private async workspaceChange(
|
|
152
|
+
after: number,
|
|
153
|
+
waitSeconds: number,
|
|
154
|
+
signal?: AbortSignal,
|
|
155
|
+
): Promise<{ cursor: number; agentState: string } | undefined> {
|
|
156
|
+
if (!this.changesUrl) return undefined;
|
|
157
|
+
const url = new URL(this.changesUrl);
|
|
158
|
+
url.searchParams.set("after", String(after));
|
|
159
|
+
url.searchParams.set("wait", String(waitSeconds));
|
|
160
|
+
const response = await this.fetchImpl(url, {
|
|
161
|
+
headers: { authorization: `Bearer ${this.key}` },
|
|
162
|
+
signal,
|
|
163
|
+
});
|
|
164
|
+
if (response.status === 404) {
|
|
165
|
+
this.changesUrl = undefined;
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
if (!response.ok) {
|
|
169
|
+
throw new Error(`PocketCoder changes request failed: ${await responseError(response)}`);
|
|
170
|
+
}
|
|
171
|
+
const body = (await response.json()) as WorkspaceChange;
|
|
172
|
+
if (
|
|
173
|
+
typeof body.cursor !== "number" ||
|
|
174
|
+
!isRecord(body.workspace) ||
|
|
175
|
+
(body.workspace.agent_state !== "unknown" &&
|
|
176
|
+
body.workspace.agent_state !== "running" &&
|
|
177
|
+
body.workspace.agent_state !== "stable")
|
|
178
|
+
) {
|
|
179
|
+
throw new Error("PocketCoder changes response was malformed");
|
|
180
|
+
}
|
|
181
|
+
return { cursor: body.cursor, agentState: body.workspace.agent_state };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private async submit(
|
|
185
|
+
prompt: string,
|
|
186
|
+
attachmentIds: string[],
|
|
187
|
+
signal: AbortSignal,
|
|
188
|
+
): Promise<void> {
|
|
189
|
+
const response = await this.request("/message", {
|
|
190
|
+
method: "POST",
|
|
191
|
+
body: JSON.stringify({
|
|
192
|
+
content: prompt,
|
|
193
|
+
type: "user",
|
|
194
|
+
...(attachmentIds.length > 0 ? { attachment_ids: attachmentIds } : {}),
|
|
195
|
+
}),
|
|
196
|
+
signal,
|
|
197
|
+
});
|
|
198
|
+
if (!response.ok) {
|
|
199
|
+
throw new Error(`AgentAPI message request failed: ${await responseError(response)}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private async pollForReply(
|
|
204
|
+
baselineId: number,
|
|
205
|
+
changeCursor: number,
|
|
206
|
+
deadline: number,
|
|
207
|
+
signal: AbortSignal,
|
|
208
|
+
): Promise<string> {
|
|
209
|
+
while (Date.now() < deadline) {
|
|
210
|
+
const remainingMs = deadline - Date.now();
|
|
211
|
+
const change = await this.workspaceChange(
|
|
212
|
+
changeCursor,
|
|
213
|
+
Math.max(1, Math.min(30, Math.ceil(remainingMs / 1000))),
|
|
214
|
+
signal,
|
|
215
|
+
);
|
|
216
|
+
if (change) changeCursor = change.cursor;
|
|
217
|
+
const [status, messages] = change
|
|
218
|
+
? [change.agentState, await this.messages(signal)]
|
|
219
|
+
: await Promise.all([this.status(signal), this.messages(signal)]);
|
|
220
|
+
const reply = messages
|
|
221
|
+
.filter((message) => message.id > baselineId && isAgentMessage(message))
|
|
222
|
+
.at(-1);
|
|
223
|
+
if (status === "stable" && reply?.content.trim()) return reply.content;
|
|
224
|
+
if (!change) await delay(this.pollIntervalMs, signal);
|
|
225
|
+
}
|
|
226
|
+
throw new Error(`remote agent did not finish within ${this.timeoutMs}ms`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private async consumeEvents(
|
|
230
|
+
initial: Response,
|
|
231
|
+
onEvent: (event: AgentApiEvent) => Promise<void>,
|
|
232
|
+
signal: AbortSignal,
|
|
233
|
+
): Promise<"fallback" | "aborted"> {
|
|
234
|
+
let response = initial;
|
|
235
|
+
while (!signal.aborted) {
|
|
236
|
+
try {
|
|
237
|
+
if (!response.body) throw new Error("AgentAPI events response had no body");
|
|
238
|
+
for await (const event of readAgentApiEvents(response.body, signal)) {
|
|
239
|
+
await onEvent(event);
|
|
240
|
+
}
|
|
241
|
+
} catch {
|
|
242
|
+
if (signal.aborted) return "aborted";
|
|
243
|
+
}
|
|
244
|
+
if (signal.aborted) return "aborted";
|
|
245
|
+
await delay(this.pollIntervalMs, signal);
|
|
246
|
+
try {
|
|
247
|
+
const reconnected = await this.eventStream(signal);
|
|
248
|
+
if (!reconnected) return "fallback";
|
|
249
|
+
response = reconnected;
|
|
250
|
+
} catch {
|
|
251
|
+
if (signal.aborted) return "aborted";
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return "aborted";
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async send(
|
|
258
|
+
prompt: string,
|
|
259
|
+
signal?: AbortSignal,
|
|
260
|
+
attachmentIds: string[] = [],
|
|
261
|
+
onSnapshot?: SnapshotCallback,
|
|
262
|
+
): Promise<string> {
|
|
263
|
+
const timeout = new AbortController();
|
|
264
|
+
const timer = setTimeout(
|
|
265
|
+
() => timeout.abort(new Error(`remote agent did not finish within ${this.timeoutMs}ms`)),
|
|
266
|
+
this.timeoutMs,
|
|
267
|
+
);
|
|
268
|
+
const turnSignal = signal ? AbortSignal.any([signal, timeout.signal]) : timeout.signal;
|
|
269
|
+
const stopEvents = new AbortController();
|
|
270
|
+
const eventSignal = AbortSignal.any([turnSignal, stopEvents.signal]);
|
|
271
|
+
try {
|
|
272
|
+
const before = await this.messages(turnSignal);
|
|
273
|
+
const baselineId = before.reduce((maximum, message) => Math.max(maximum, message.id), -1);
|
|
274
|
+
const baselineChange = await this.workspaceChange(0, 0, turnSignal);
|
|
275
|
+
const deadline = Date.now() + this.timeoutMs;
|
|
276
|
+
const initialEvents = await this.eventStream(eventSignal);
|
|
277
|
+
if (!initialEvents) {
|
|
278
|
+
await this.submit(prompt, attachmentIds, turnSignal);
|
|
279
|
+
return await this.pollForReply(
|
|
280
|
+
baselineId,
|
|
281
|
+
baselineChange?.cursor ?? 0,
|
|
282
|
+
deadline,
|
|
283
|
+
turnSignal,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
let submitted = false;
|
|
288
|
+
let lastSnapshot = "";
|
|
289
|
+
let complete!: (value: string) => void;
|
|
290
|
+
const completed = new Promise<string>((resolve) => {
|
|
291
|
+
complete = resolve;
|
|
292
|
+
});
|
|
293
|
+
const consume = this.consumeEvents(
|
|
294
|
+
initialEvents,
|
|
295
|
+
async (event) => {
|
|
296
|
+
if (
|
|
297
|
+
event.event === "message_update" &&
|
|
298
|
+
event.data.id > baselineId &&
|
|
299
|
+
(event.data.role === "agent" || event.data.role === "assistant") &&
|
|
300
|
+
event.data.message.trim() &&
|
|
301
|
+
event.data.message !== lastSnapshot
|
|
302
|
+
) {
|
|
303
|
+
lastSnapshot = event.data.message;
|
|
304
|
+
onSnapshot?.(lastSnapshot);
|
|
305
|
+
}
|
|
306
|
+
if (event.event !== "status_change" || event.data.status !== "stable" || !submitted) {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const final = (await this.messages(turnSignal))
|
|
310
|
+
.filter((message) => message.id > baselineId && isAgentMessage(message))
|
|
311
|
+
.at(-1);
|
|
312
|
+
if (!final?.content.trim()) return;
|
|
313
|
+
if (final.content !== lastSnapshot) onSnapshot?.(final.content);
|
|
314
|
+
complete(final.content);
|
|
315
|
+
},
|
|
316
|
+
eventSignal,
|
|
317
|
+
);
|
|
318
|
+
submitted = true;
|
|
319
|
+
await this.submit(prompt, attachmentIds, turnSignal);
|
|
320
|
+
const outcome = await Promise.race([
|
|
321
|
+
completed.then((value) => ({ kind: "complete" as const, value })),
|
|
322
|
+
consume.then((result) => ({ kind: result })),
|
|
323
|
+
]);
|
|
324
|
+
if (outcome.kind === "complete") return outcome.value;
|
|
325
|
+
if (outcome.kind === "fallback") {
|
|
326
|
+
return await this.pollForReply(
|
|
327
|
+
baselineId,
|
|
328
|
+
baselineChange?.cursor ?? 0,
|
|
329
|
+
deadline,
|
|
330
|
+
turnSignal,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
throw turnSignal.reason ?? new Error("remote request aborted");
|
|
334
|
+
} catch (error) {
|
|
335
|
+
if (timeout.signal.aborted) throw timeout.signal.reason;
|
|
336
|
+
throw error;
|
|
337
|
+
} finally {
|
|
338
|
+
clearTimeout(timer);
|
|
339
|
+
stopEvents.abort("turn ended");
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export { serviceUrlFromEnvironment } from "./environment";
|
package/src/commands.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { ControlPlaneClient, WorkspaceSummary } from "./control-plane";
|
|
3
|
+
import { WorkspaceTerminalError } from "./control-plane";
|
|
4
|
+
import { relayTarget, type TargetRef } from "./session-target";
|
|
5
|
+
|
|
6
|
+
export interface CommandUi {
|
|
7
|
+
select(title: string, options: string[]): Promise<string | undefined>;
|
|
8
|
+
confirm(title: string, message: string): Promise<boolean>;
|
|
9
|
+
input(title: string, placeholder?: string): Promise<string | undefined>;
|
|
10
|
+
notify(message: string, type?: "info" | "warning" | "error"): void;
|
|
11
|
+
setWorkingMessage(message?: string): void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CommandContext {
|
|
15
|
+
hasUI: boolean;
|
|
16
|
+
ui: CommandUi;
|
|
17
|
+
newSession(): Promise<{ cancelled: boolean }>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface CommandRegistrar {
|
|
21
|
+
registerCommand(
|
|
22
|
+
name: string,
|
|
23
|
+
options: {
|
|
24
|
+
description?: string;
|
|
25
|
+
handler: (args: string, ctx: CommandContext) => Promise<void>;
|
|
26
|
+
},
|
|
27
|
+
): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface WorkspaceCommandDeps {
|
|
31
|
+
targets: TargetRef;
|
|
32
|
+
controlPlane: ControlPlaneClient;
|
|
33
|
+
waitTimeoutMs?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function workspaceLabel(workspace: WorkspaceSummary): string {
|
|
37
|
+
return `${workspace.external_id} · ${workspace.template.name} · ${workspace.id.slice(0, 8)}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function switchToWorkspace(
|
|
41
|
+
deps: WorkspaceCommandDeps,
|
|
42
|
+
ctx: CommandContext,
|
|
43
|
+
workspaceId: string,
|
|
44
|
+
): Promise<void> {
|
|
45
|
+
const target = deps.targets.current;
|
|
46
|
+
if (target.mode === "direct") return;
|
|
47
|
+
deps.targets.set(relayTarget(target.baseUrl, target.key, workspaceId));
|
|
48
|
+
await ctx.newSession();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function requireRelayCapable(deps: WorkspaceCommandDeps, ctx: CommandContext): boolean {
|
|
52
|
+
if (!ctx.hasUI) return false;
|
|
53
|
+
if (deps.targets.current.mode === "direct") {
|
|
54
|
+
ctx.ui.notify(
|
|
55
|
+
"workspace commands need a PocketCoder server (POCKETCODER_URL), not a direct AgentAPI URL",
|
|
56
|
+
"warning",
|
|
57
|
+
);
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function pickWorkspace(deps: WorkspaceCommandDeps, ctx: CommandContext): Promise<void> {
|
|
64
|
+
const workspaces = (await deps.controlPlane.workspaces.list({ state: "ready" })).items;
|
|
65
|
+
if (workspaces.length === 0) {
|
|
66
|
+
ctx.ui.notify("no ready workspaces; use /workspace-create", "info");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const labels = workspaces.map(workspaceLabel);
|
|
70
|
+
const selection = await ctx.ui.select("Switch workspace", labels);
|
|
71
|
+
if (selection === undefined) return;
|
|
72
|
+
const workspace = workspaces[labels.indexOf(selection)];
|
|
73
|
+
if (workspace) await switchToWorkspace(deps, ctx, workspace.id);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function createWorkspace(deps: WorkspaceCommandDeps, ctx: CommandContext): Promise<void> {
|
|
77
|
+
const templates = await deps.controlPlane.templates.list();
|
|
78
|
+
if (templates.length === 0) {
|
|
79
|
+
ctx.ui.notify("no templates available to this key", "warning");
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const labels = templates.map((template) => `${template.name}@${template.version}`);
|
|
83
|
+
const selection = await ctx.ui.select("Create workspace from template", labels);
|
|
84
|
+
if (selection === undefined) return;
|
|
85
|
+
const template = templates[labels.indexOf(selection)];
|
|
86
|
+
if (!template) return;
|
|
87
|
+
const externalId =
|
|
88
|
+
(await ctx.ui.input("External id", `pi-${randomUUID()}`))?.trim() || `pi-${randomUUID()}`;
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
ctx.ui.setWorkingMessage(`creating workspace ${externalId}`);
|
|
92
|
+
const created = await deps.controlPlane.workspaces.create({
|
|
93
|
+
externalId,
|
|
94
|
+
templateName: template.name,
|
|
95
|
+
});
|
|
96
|
+
const ready = await deps.controlPlane.workspaces.waitForReady(
|
|
97
|
+
created,
|
|
98
|
+
deps.waitTimeoutMs ?? 300_000,
|
|
99
|
+
{
|
|
100
|
+
onTick: (workspace) => ctx.ui.setWorkingMessage(`workspace ${workspace.state}`),
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
await switchToWorkspace(deps, ctx, ready.id);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
const message =
|
|
106
|
+
error instanceof WorkspaceTerminalError
|
|
107
|
+
? error.message
|
|
108
|
+
: `workspace create failed: ${String(error)}`;
|
|
109
|
+
ctx.ui.notify(message, "error");
|
|
110
|
+
} finally {
|
|
111
|
+
ctx.ui.setWorkingMessage();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function cancelWorkspace(deps: WorkspaceCommandDeps, ctx: CommandContext): Promise<void> {
|
|
116
|
+
const target = deps.targets.current;
|
|
117
|
+
if (target.mode !== "relay") {
|
|
118
|
+
ctx.ui.notify("no workspace attached", "info");
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const confirmed = await ctx.ui.confirm(
|
|
122
|
+
"Cancel workspace",
|
|
123
|
+
`Cancel workspace ${target.workspaceId}? The remote agent stops and the workspace is discarded.`,
|
|
124
|
+
);
|
|
125
|
+
if (!confirmed) return;
|
|
126
|
+
await deps.controlPlane.workspaces.cancel(target.workspaceId);
|
|
127
|
+
ctx.ui.notify(`workspace ${target.workspaceId.slice(0, 8)} canceled`, "info");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function registerWorkspaceCommands(pi: CommandRegistrar, deps: WorkspaceCommandDeps): void {
|
|
131
|
+
pi.registerCommand("workspace", {
|
|
132
|
+
description: "Switch to another ready PocketCoder workspace",
|
|
133
|
+
handler: async (_args, ctx) => {
|
|
134
|
+
if (requireRelayCapable(deps, ctx)) await pickWorkspace(deps, ctx);
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
pi.registerCommand("workspace-create", {
|
|
138
|
+
description: "Create a PocketCoder workspace from a template and switch to it",
|
|
139
|
+
handler: async (_args, ctx) => {
|
|
140
|
+
if (requireRelayCapable(deps, ctx)) await createWorkspace(deps, ctx);
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
pi.registerCommand("workspace-cancel", {
|
|
144
|
+
description: "Cancel the current PocketCoder workspace",
|
|
145
|
+
handler: async (_args, ctx) => {
|
|
146
|
+
if (requireRelayCapable(deps, ctx)) await cancelWorkspace(deps, ctx);
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ConversationGoneError,
|
|
3
|
+
type ConversationMessage,
|
|
4
|
+
PocketCoderClient,
|
|
5
|
+
PocketCoderError,
|
|
6
|
+
TERMINAL_WORKSPACE_STATES,
|
|
7
|
+
type WorkspaceSummary,
|
|
8
|
+
WorkspaceTerminalError,
|
|
9
|
+
} from "@pstdio/pocketcoder-client";
|
|
10
|
+
|
|
11
|
+
export {
|
|
12
|
+
ConversationGoneError,
|
|
13
|
+
type ConversationMessage,
|
|
14
|
+
PocketCoderError as ControlPlaneError,
|
|
15
|
+
TERMINAL_WORKSPACE_STATES,
|
|
16
|
+
type WorkspaceSummary,
|
|
17
|
+
WorkspaceTerminalError,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export interface ControlPlaneConfig {
|
|
21
|
+
baseUrl: string;
|
|
22
|
+
key: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** @internal The remote UI keeps its historical config vocabulary at its boundary. */
|
|
26
|
+
export class ControlPlaneClient extends PocketCoderClient {
|
|
27
|
+
constructor(config: ControlPlaneConfig, fetchImpl: typeof fetch = fetch) {
|
|
28
|
+
super({ baseUrl: config.baseUrl, apiKey: config.key }, fetchImpl);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function serviceUrlFromEnvironment(env: NodeJS.ProcessEnv = process.env): {
|
|
2
|
+
serviceUrl: string;
|
|
3
|
+
key: string;
|
|
4
|
+
} {
|
|
5
|
+
const directUrl = env.POCKETCODER_AGENTAPI_URL;
|
|
6
|
+
const key = env.POCKETCODER_KEY;
|
|
7
|
+
if (!key) throw new Error("POCKETCODER_KEY is required");
|
|
8
|
+
if (directUrl) return { serviceUrl: directUrl, key };
|
|
9
|
+
|
|
10
|
+
const baseUrl = (env.POCKETCODER_URL ?? "http://127.0.0.1:7080").replace(/\/$/, "");
|
|
11
|
+
const workspaceId = env.POCKETCODER_WORKSPACE_ID;
|
|
12
|
+
if (!workspaceId) {
|
|
13
|
+
throw new Error("POCKETCODER_WORKSPACE_ID or POCKETCODER_AGENTAPI_URL is required");
|
|
14
|
+
}
|
|
15
|
+
return {
|
|
16
|
+
serviceUrl: `${baseUrl}/v1/workspaces/${encodeURIComponent(workspaceId)}/agent`,
|
|
17
|
+
key,
|
|
18
|
+
};
|
|
19
|
+
}
|