@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.
@@ -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
+ }
@@ -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
+ }