@jameslovespancakes/pi-plus 1.0.19 → 1.0.21
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/README.md +133 -246
- package/package.json +3 -2
- package/src/core/claude-remote/LICENSE.md +22 -0
- package/src/core/claude-remote/UPSTREAM.md +40 -0
- package/src/core/claude-remote/bridge.ts +392 -0
- package/src/core/claude-remote/protocol.ts +83 -0
- package/src/core/env.ts +7 -1
- package/src/domains/claude-remote/auth.ts +25 -0
- package/src/domains/claude-remote/index.ts +183 -0
- package/src/domains/claude-remote/picker.ts +36 -0
- package/src/domains/models/provider-picker.ts +3 -46
- package/src/domains/setup/index.ts +12 -1
- package/src/domains/workflows/index.ts +56 -104
- package/src/domains/workflows/runtime/advisory-challenge.ts +3 -3
- package/src/domains/workflows/runtime/agent-attempt.ts +3 -3
- package/src/domains/workflows/runtime/agent-options.ts +18 -0
- package/src/domains/workflows/runtime/agent-runner-types.ts +7 -3
- package/src/domains/workflows/runtime/agent-runner.ts +14 -6
- package/src/domains/workflows/runtime/agent-session.ts +34 -3
- package/src/domains/workflows/runtime/cancellation.ts +5 -0
- package/src/domains/workflows/runtime/engine.ts +19 -40
- package/src/domains/workflows/runtime/journal.ts +4 -4
- package/src/domains/workflows/runtime/live-agent.ts +37 -0
- package/src/domains/workflows/runtime/model-profiles.ts +2 -6
- package/src/domains/workflows/runtime/progress-types.ts +3 -1
- package/src/domains/workflows/runtime/progress.ts +69 -42
- package/src/domains/workflows/runtime/review/review-fix-workflow.ts +3 -3
- package/src/domains/workflows/runtime/types.ts +16 -15
- package/src/domains/workflows/runtime/ui/agent-transcript.ts +59 -0
- package/src/domains/workflows/runtime/ui/workflow-format.ts +6 -2
- package/src/domains/workflows/runtime/ui/workflow-inspector.ts +75 -61
- package/src/domains/workflows/runtime/ui/workflow-widget.ts +26 -66
- package/src/domains/workflows/runtime/workflow-advisory-utils.ts +5 -5
- package/src/domains/workflows/runtime/{background-workflows.ts → workflow-lifecycle.ts} +91 -75
- package/src/domains/workflows/runtime/workflow-management.ts +66 -0
- package/src/domains/workflows/runtime/workflow-run-controller.ts +16 -20
- package/src/domains/workflows/runtime/{workflow-run-background.ts → workflow-run-delivery.ts} +3 -3
- package/src/domains/workflows/runtime/workflow-run-record.ts +7 -4
- package/src/domains/workflows/workflows/code-review.ts +1 -1
- package/src/domains/workflows/workflows/diagnose.ts +1 -1
- package/src/domains/workflows/workflows/perf-review.ts +1 -1
- package/src/domains/workflows/workflows/refactor-scout.ts +1 -1
- package/src/domains/workflows/workflows/research.ts +4 -4
- package/src/ui/settings-picker.ts +26 -0
- package/src/domains/workflows/runtime/background-workflow-tool.ts +0 -75
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
3
|
+
import { inboundText, parseSSE, RecentIds, record, type RemoteMessage } from "./protocol.ts";
|
|
4
|
+
|
|
5
|
+
const API = "https://api.anthropic.com";
|
|
6
|
+
const MAX_BYTES = 8 * 1024 * 1024;
|
|
7
|
+
const MAX_EVENT_BYTES = 4 * 1024 * 1024;
|
|
8
|
+
const MAX_QUEUE = 512;
|
|
9
|
+
const MAX_FRAME = 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
type State = "idle" | "running";
|
|
12
|
+
type Operation =
|
|
13
|
+
| { kind: "event"; json: string; bytes: number }
|
|
14
|
+
| { kind: "state"; state: State }
|
|
15
|
+
| { kind: "ack"; id: string }
|
|
16
|
+
| { kind: "heartbeat" }
|
|
17
|
+
| { kind: "refresh" };
|
|
18
|
+
|
|
19
|
+
interface Credentials {
|
|
20
|
+
worker_jwt: string;
|
|
21
|
+
api_base_url: string;
|
|
22
|
+
expires_in: number;
|
|
23
|
+
worker_epoch: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface BridgeOptions {
|
|
27
|
+
getAccessToken(signal: AbortSignal): Promise<string>;
|
|
28
|
+
title: string;
|
|
29
|
+
trustedDeviceToken?: string;
|
|
30
|
+
allowInbound: boolean;
|
|
31
|
+
onText(text: string): void;
|
|
32
|
+
onInterrupt(): void;
|
|
33
|
+
onConnect(id: string): void;
|
|
34
|
+
onConnectionChange?(connected: boolean): void;
|
|
35
|
+
onError(message: string): void;
|
|
36
|
+
/** Injected for offline protocol tests; production always uses native fetch. */
|
|
37
|
+
fetch?: typeof fetch;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
class HttpError extends Error {
|
|
41
|
+
readonly status: number;
|
|
42
|
+
constructor(status: number) {
|
|
43
|
+
super(`Claude Remote HTTP ${status}`);
|
|
44
|
+
this.status = status;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A single session, with no inference, auth persistence or model-context hooks.
|
|
49
|
+
* Protocol adapted from clepdn/claude-remote-lib; see UPSTREAM.md.
|
|
50
|
+
* All mutations (including epoch refresh) share one bounded, ordered writer.
|
|
51
|
+
*/
|
|
52
|
+
export class ClaudeRemoteBridge {
|
|
53
|
+
private readonly options: BridgeOptions;
|
|
54
|
+
private readonly fetcher: typeof fetch;
|
|
55
|
+
private readonly lifetime = new AbortController();
|
|
56
|
+
private stream?: AbortController;
|
|
57
|
+
private heartbeat?: ReturnType<typeof setInterval>;
|
|
58
|
+
private refreshTimer?: ReturnType<typeof setTimeout>;
|
|
59
|
+
private credentials?: Credentials;
|
|
60
|
+
private id?: string;
|
|
61
|
+
private queue: Operation[] = [];
|
|
62
|
+
private bytes = 0;
|
|
63
|
+
private writing = false;
|
|
64
|
+
private ready = false;
|
|
65
|
+
private started = false;
|
|
66
|
+
private state: State = "idle";
|
|
67
|
+
private readonly seen = new RecentIds();
|
|
68
|
+
private readonly posted = new RecentIds();
|
|
69
|
+
|
|
70
|
+
constructor(options: BridgeOptions) {
|
|
71
|
+
this.options = options;
|
|
72
|
+
this.fetcher = options.fetch ?? fetch;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
get sessionId(): string | undefined { return this.id; }
|
|
76
|
+
get closed(): boolean { return this.lifetime.signal.aborted; }
|
|
77
|
+
|
|
78
|
+
async start(): Promise<void> {
|
|
79
|
+
if (this.started || this.closed) return;
|
|
80
|
+
this.started = true;
|
|
81
|
+
try {
|
|
82
|
+
const token = await this.options.getAccessToken(this.lifetime.signal);
|
|
83
|
+
this.lifetime.signal.throwIfAborted();
|
|
84
|
+
const response = await this.request(`${API}/v1/code/sessions`, token, "POST", {
|
|
85
|
+
title: this.options.title, bridge: {}, tags: ["pi-plus"],
|
|
86
|
+
});
|
|
87
|
+
const data: unknown = await response.json();
|
|
88
|
+
if (!record(data) || !record(data.session) || typeof data.session.id !== "string"
|
|
89
|
+
|| !/^cse_[\w-]+$/.test(data.session.id)) throw new Error("Invalid code session response");
|
|
90
|
+
this.id = data.session.id;
|
|
91
|
+
await this.refreshCredentials(token);
|
|
92
|
+
if (this.closed) return;
|
|
93
|
+
this.ready = true;
|
|
94
|
+
this.heartbeat = setInterval(() => this.enqueue({ kind: "heartbeat" }), 20_000);
|
|
95
|
+
this.heartbeat.unref();
|
|
96
|
+
this.options.onConnect(this.id);
|
|
97
|
+
this.drain();
|
|
98
|
+
} catch (error) {
|
|
99
|
+
this.fail(error);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Synchronous snapshots: pi can mutate its own messages after this returns. */
|
|
104
|
+
send(message: RemoteMessage): void {
|
|
105
|
+
if (this.closed) return;
|
|
106
|
+
try {
|
|
107
|
+
const uuid = randomUUID();
|
|
108
|
+
const json = JSON.stringify({ ...message, uuid, parent_tool_use_id: null });
|
|
109
|
+
const bytes = Buffer.byteLength(json);
|
|
110
|
+
if (bytes > MAX_EVENT_BYTES) throw new Error("Remote message exceeds the 4 MiB limit");
|
|
111
|
+
this.posted.add(uuid);
|
|
112
|
+
this.enqueue({ kind: "event", json, bytes });
|
|
113
|
+
} catch (error) { this.fail(error); }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
reportState(state: State): void {
|
|
117
|
+
this.state = state;
|
|
118
|
+
this.enqueue({ kind: "state", state });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
stop(): void {
|
|
122
|
+
if (this.closed) return;
|
|
123
|
+
this.lifetime.abort();
|
|
124
|
+
this.stream?.abort();
|
|
125
|
+
clearInterval(this.heartbeat);
|
|
126
|
+
clearTimeout(this.refreshTimer);
|
|
127
|
+
this.queue = [];
|
|
128
|
+
this.bytes = 0;
|
|
129
|
+
this.ready = false;
|
|
130
|
+
this.credentials = undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private fail(error: unknown): void {
|
|
134
|
+
if (this.closed) return;
|
|
135
|
+
this.stop();
|
|
136
|
+
// Never surface response bodies, URLs, OAuth errors, or arbitrary exception text.
|
|
137
|
+
const reason = error instanceof HttpError ? error.message
|
|
138
|
+
: error instanceof Error && /^(Remote message exceeds|Remote queue is full|Invalid code session|Invalid worker)/.test(error.message)
|
|
139
|
+
? error.message : "Claude Remote connection failed; check your Anthropic login and retry /claude-remote on";
|
|
140
|
+
this.options.onError(reason);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private enqueue(operation: Operation): void {
|
|
144
|
+
if (this.closed) return;
|
|
145
|
+
if ((operation.kind === "heartbeat" || operation.kind === "refresh")
|
|
146
|
+
&& this.queue.some((item) => item.kind === operation.kind)) return;
|
|
147
|
+
const bytes = operation.kind === "event" ? operation.bytes : 0;
|
|
148
|
+
if (this.queue.length >= MAX_QUEUE || this.bytes + bytes > MAX_BYTES) {
|
|
149
|
+
this.fail(new Error("Remote queue is full; mirroring stopped without affecting pi"));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
this.queue.push(operation);
|
|
153
|
+
this.bytes += bytes;
|
|
154
|
+
this.drain();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private drain(): void {
|
|
158
|
+
if (this.writing || !this.ready || this.closed) return;
|
|
159
|
+
this.writing = true;
|
|
160
|
+
void this.writeLoop().catch((error) => this.fail(error)).finally(() => {
|
|
161
|
+
this.writing = false;
|
|
162
|
+
if (!this.closed && this.queue.length) this.drain();
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private async writeLoop(): Promise<void> {
|
|
167
|
+
while (!this.closed && this.queue.length) {
|
|
168
|
+
const op = this.queue.shift()!;
|
|
169
|
+
if (op.kind === "event") {
|
|
170
|
+
const batch = [op];
|
|
171
|
+
let size = op.bytes;
|
|
172
|
+
while (batch.length < 50 && this.queue[0]?.kind === "event") {
|
|
173
|
+
const next = this.queue[0];
|
|
174
|
+
if (size + next.bytes > 512 * 1024) break;
|
|
175
|
+
batch.push(this.queue.shift() as typeof op);
|
|
176
|
+
size += next.bytes;
|
|
177
|
+
}
|
|
178
|
+
this.bytes -= size;
|
|
179
|
+
await this.retry(() => this.worker("/events", "POST", {
|
|
180
|
+
events: batch.map((item) => ({ payload: { ...JSON.parse(item.json), session_id: this.id } })),
|
|
181
|
+
}));
|
|
182
|
+
} else if (op.kind === "refresh") {
|
|
183
|
+
await this.retry(() => this.refreshCredentials());
|
|
184
|
+
} else if (op.kind === "state") {
|
|
185
|
+
await this.retry(() => this.worker("", "PUT", { worker_status: op.state, external_metadata: {} }));
|
|
186
|
+
} else if (op.kind === "heartbeat") {
|
|
187
|
+
await this.retry(() => this.worker("/heartbeat", "POST", { session_id: this.id }));
|
|
188
|
+
} else {
|
|
189
|
+
await this.retry(() => this.worker("/events/delivery", "POST", {
|
|
190
|
+
updates: [{ event_id: op.id, status: "processed" }],
|
|
191
|
+
}));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private async retry<T>(work: () => Promise<T>): Promise<T> {
|
|
197
|
+
for (let attempt = 0; ; attempt++) {
|
|
198
|
+
this.lifetime.signal.throwIfAborted();
|
|
199
|
+
try { return await work(); } catch (error) {
|
|
200
|
+
if (this.closed || attempt >= 3 || (error instanceof HttpError && error.status < 500 && error.status !== 429)) throw error;
|
|
201
|
+
await delay(1000 * 2 ** attempt, undefined, { signal: this.lifetime.signal });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private async request(url: string, token: string, method: string, body: unknown, extra?: Record<string, string>): Promise<Response> {
|
|
207
|
+
this.lifetime.signal.throwIfAborted();
|
|
208
|
+
const response = await this.fetcher(url, {
|
|
209
|
+
method,
|
|
210
|
+
headers: { Authorization: `Bearer ${token}`, "anthropic-version": "2023-06-01", "Content-Type": "application/json", ...extra },
|
|
211
|
+
body: JSON.stringify(body),
|
|
212
|
+
signal: AbortSignal.any([this.lifetime.signal, AbortSignal.timeout(10_000)]),
|
|
213
|
+
redirect: "error",
|
|
214
|
+
});
|
|
215
|
+
if (!response.ok) {
|
|
216
|
+
await response.body?.cancel();
|
|
217
|
+
throw new HttpError(response.status);
|
|
218
|
+
}
|
|
219
|
+
return response;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private async worker(path: string, method: string, body: Record<string, unknown>): Promise<void> {
|
|
223
|
+
const creds = this.credentials;
|
|
224
|
+
if (!creds) throw new Error("Worker not connected");
|
|
225
|
+
const response = await this.request(this.workerUrl(creds) + path, creds.worker_jwt, method,
|
|
226
|
+
{ ...body, worker_epoch: creds.worker_epoch });
|
|
227
|
+
await response.body?.cancel();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private workerUrl(creds: Credentials): string {
|
|
231
|
+
return `${creds.api_base_url.replace(/\/+$/, "")}/v1/code/sessions/${this.id}/worker`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private async refreshCredentials(accessToken?: string): Promise<void> {
|
|
235
|
+
const token = accessToken ?? await this.options.getAccessToken(this.lifetime.signal);
|
|
236
|
+
this.lifetime.signal.throwIfAborted();
|
|
237
|
+
// A /bridge call invalidates the previous epoch. No writer runs concurrently.
|
|
238
|
+
this.options.onConnectionChange?.(false);
|
|
239
|
+
this.stream?.abort();
|
|
240
|
+
const response = await this.request(`${API}/v1/code/sessions/${this.id}/bridge`, token, "POST", {},
|
|
241
|
+
this.options.trustedDeviceToken ? { "X-Trusted-Device-Token": this.options.trustedDeviceToken } : undefined);
|
|
242
|
+
const data: unknown = await response.json();
|
|
243
|
+
if (!record(data) || typeof data.worker_jwt !== "string" || !data.worker_jwt
|
|
244
|
+
|| typeof data.api_base_url !== "string" || typeof data.expires_in !== "number"
|
|
245
|
+
|| !Number.isFinite(data.expires_in) || data.expires_in <= 0
|
|
246
|
+
|| !(typeof data.worker_epoch === "number" || typeof data.worker_epoch === "string")
|
|
247
|
+
|| !Number.isSafeInteger(Number(data.worker_epoch)) || Number(data.worker_epoch) < 1) {
|
|
248
|
+
throw new Error("Invalid worker credentials");
|
|
249
|
+
}
|
|
250
|
+
const url = new URL(data.api_base_url);
|
|
251
|
+
if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash) {
|
|
252
|
+
throw new Error("Invalid worker endpoint");
|
|
253
|
+
}
|
|
254
|
+
this.lifetime.signal.throwIfAborted();
|
|
255
|
+
const credentials = { worker_jwt: data.worker_jwt, api_base_url: data.api_base_url,
|
|
256
|
+
expires_in: data.expires_in, worker_epoch: Number(data.worker_epoch) };
|
|
257
|
+
this.credentials = credentials;
|
|
258
|
+
await this.worker("", "PUT", { worker_status: this.state, external_metadata: { pending_action: null, task_summary: null } });
|
|
259
|
+
this.lifetime.signal.throwIfAborted();
|
|
260
|
+
await this.openStream(credentials);
|
|
261
|
+
this.lifetime.signal.throwIfAborted();
|
|
262
|
+
clearTimeout(this.refreshTimer);
|
|
263
|
+
// Five-minute headroom for ordinary TTLs; short TTLs refresh at 80% instead.
|
|
264
|
+
const ttl = data.expires_in * 1000;
|
|
265
|
+
const wait = Math.min(2_147_483_647, Math.max(1000, ttl - Math.min(300_000, ttl * 0.2)));
|
|
266
|
+
this.refreshTimer = setTimeout(() => this.enqueue({ kind: "refresh" }), wait);
|
|
267
|
+
this.refreshTimer.unref();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private async openStream(creds: Credentials): Promise<void> {
|
|
271
|
+
const controller = new AbortController();
|
|
272
|
+
this.stream = controller;
|
|
273
|
+
const signal = AbortSignal.any([controller.signal, this.lifetime.signal]);
|
|
274
|
+
// Resolve on headers, not on EOF: heartbeat and refresh must run while SSE is open.
|
|
275
|
+
const response = await this.fetchStream(creds, signal, 0);
|
|
276
|
+
if (signal.aborted) {
|
|
277
|
+
await response.body?.cancel();
|
|
278
|
+
signal.throwIfAborted();
|
|
279
|
+
}
|
|
280
|
+
this.options.onConnectionChange?.(true);
|
|
281
|
+
void this.readLoop(creds, response, signal).catch((error) => {
|
|
282
|
+
if (!signal.aborted) this.fail(error);
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private async fetchStream(creds: Credentials, signal: AbortSignal, sequence: number): Promise<Response> {
|
|
287
|
+
const timeout = new AbortController();
|
|
288
|
+
const timer = setTimeout(() => timeout.abort(), 10_000);
|
|
289
|
+
try {
|
|
290
|
+
const url = new URL(this.workerUrl(creds) + "/events/stream");
|
|
291
|
+
if (sequence > 0) url.searchParams.set("from_sequence_num", String(sequence));
|
|
292
|
+
const response = await this.fetcher(url, {
|
|
293
|
+
headers: { Authorization: `Bearer ${creds.worker_jwt}`, "anthropic-version": "2023-06-01",
|
|
294
|
+
Accept: "text/event-stream", "Cache-Control": "no-cache", "Last-Event-ID": String(sequence) },
|
|
295
|
+
signal: AbortSignal.any([signal, timeout.signal]), redirect: "error",
|
|
296
|
+
});
|
|
297
|
+
if (!response.ok || !response.body) {
|
|
298
|
+
await response.body?.cancel();
|
|
299
|
+
throw new HttpError(response.status);
|
|
300
|
+
}
|
|
301
|
+
return response;
|
|
302
|
+
} finally { clearTimeout(timer); }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private async readLoop(creds: Credentials, initial: Response, signal: AbortSignal): Promise<void> {
|
|
306
|
+
let response = initial;
|
|
307
|
+
let sequence = 0;
|
|
308
|
+
let failures = 0;
|
|
309
|
+
while (!signal.aborted) {
|
|
310
|
+
const reader = response.body!.getReader();
|
|
311
|
+
const decoder = new TextDecoder();
|
|
312
|
+
let buffer = "";
|
|
313
|
+
try {
|
|
314
|
+
while (!signal.aborted) {
|
|
315
|
+
const chunk = await reader.read();
|
|
316
|
+
if (chunk.done) break;
|
|
317
|
+
buffer += decoder.decode(chunk.value, { stream: true });
|
|
318
|
+
if (buffer.length > MAX_FRAME) throw new Error("SSE frame too large");
|
|
319
|
+
const parsed = parseSSE(buffer);
|
|
320
|
+
buffer = parsed.remaining;
|
|
321
|
+
for (const frame of parsed.frames) {
|
|
322
|
+
let event: unknown;
|
|
323
|
+
try { event = JSON.parse(frame); } catch { continue; }
|
|
324
|
+
if (!record(event) || !record(event.payload)) continue;
|
|
325
|
+
this.receive(event.payload, typeof event.event_id === "string" ? event.event_id : undefined);
|
|
326
|
+
if (typeof event.sequence_num === "number" && Number.isSafeInteger(event.sequence_num)) {
|
|
327
|
+
sequence = Math.max(sequence, event.sequence_num);
|
|
328
|
+
}
|
|
329
|
+
failures = 0;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
} catch (error) {
|
|
333
|
+
if (signal.aborted) return;
|
|
334
|
+
if (error instanceof Error && error.message === "SSE frame too large") throw error;
|
|
335
|
+
} finally {
|
|
336
|
+
await reader.cancel().catch(() => {});
|
|
337
|
+
reader.releaseLock();
|
|
338
|
+
}
|
|
339
|
+
if (!signal.aborted) this.options.onConnectionChange?.(false);
|
|
340
|
+
while (!signal.aborted) {
|
|
341
|
+
if (++failures > 5) throw new Error("SSE reconnect exhausted");
|
|
342
|
+
await delay(Math.min(1000 * 2 ** (failures - 1), 16_000), undefined, { signal });
|
|
343
|
+
try {
|
|
344
|
+
response = await this.fetchStream(creds, signal, sequence);
|
|
345
|
+
if (!signal.aborted) this.options.onConnectionChange?.(true);
|
|
346
|
+
break;
|
|
347
|
+
} catch (error) {
|
|
348
|
+
if (error instanceof HttpError && error.status < 500 && error.status !== 429) throw error;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private receive(payload: Record<string, unknown>, eventId?: string): void {
|
|
355
|
+
if (this.closed) return;
|
|
356
|
+
const uuid = typeof payload.uuid === "string" ? payload.uuid : undefined;
|
|
357
|
+
const key = uuid ? `uuid:${uuid}` : eventId ? `event:${eventId}` : undefined;
|
|
358
|
+
if (!(key && this.seen.has(key)) && !(uuid && this.posted.has(uuid))) {
|
|
359
|
+
if (payload.type === "control_request" && typeof payload.request_id === "string" && record(payload.request)) {
|
|
360
|
+
this.control(payload.request_id, payload.request);
|
|
361
|
+
} else if (this.options.allowInbound) {
|
|
362
|
+
const text = inboundText(payload);
|
|
363
|
+
if (text && text.length <= 128 * 1024) this.options.onText(text);
|
|
364
|
+
}
|
|
365
|
+
if (key) this.seen.add(key);
|
|
366
|
+
}
|
|
367
|
+
if (eventId) this.enqueue({ kind: "ack", id: eventId });
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private control(id: string, request: Record<string, unknown>): void {
|
|
371
|
+
let response: Record<string, unknown> = {};
|
|
372
|
+
let error: string | undefined;
|
|
373
|
+
switch (request.subtype) {
|
|
374
|
+
case "initialize":
|
|
375
|
+
response = { commands: [], output_style: "normal", available_output_styles: ["normal"], models: [], account: {}, pid: process.pid };
|
|
376
|
+
break;
|
|
377
|
+
case "interrupt":
|
|
378
|
+
if (this.options.allowInbound) this.options.onInterrupt();
|
|
379
|
+
else error = "This pi mirror is read-only";
|
|
380
|
+
break;
|
|
381
|
+
case "can_use_tool":
|
|
382
|
+
response = { behavior: "deny", message: "Permissions are handled locally by pi" };
|
|
383
|
+
break;
|
|
384
|
+
default:
|
|
385
|
+
// Unlike upstream, don't claim to switch pi's model or permissions when we haven't.
|
|
386
|
+
error = "Change models, thinking and permissions in pi, not in the Claude app";
|
|
387
|
+
}
|
|
388
|
+
this.send({ type: "control_response", response: error
|
|
389
|
+
? { subtype: "error", request_id: id, error }
|
|
390
|
+
: { subtype: "success", request_id: id, response } });
|
|
391
|
+
}
|
|
392
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
3
|
+
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
|
|
4
|
+
|
|
5
|
+
/** CCR v2 wire shapes, not a second definition of pi's message types. */
|
|
6
|
+
export interface RemoteMessage {
|
|
7
|
+
type: string;
|
|
8
|
+
[key: string]: unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function record(value: unknown): value is Record<string, unknown> {
|
|
12
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function content(block: TextContent | ImageContent): Record<string, unknown> {
|
|
16
|
+
if (block.type === "text") return { type: "text", text: block.text };
|
|
17
|
+
return { type: "image", source: { type: "base64", media_type: block.mimeType, data: block.data } };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Mirror only conversation messages, never system prompts, custom context or credentials. */
|
|
21
|
+
export function mirrorMessage(message: AgentMessage): RemoteMessage | undefined {
|
|
22
|
+
switch (message.role) {
|
|
23
|
+
case "user":
|
|
24
|
+
return { type: "user", message: { role: "user", content: typeof message.content === "string"
|
|
25
|
+
? [{ type: "text", text: message.content }] : message.content.map(content) } };
|
|
26
|
+
case "assistant":
|
|
27
|
+
return {
|
|
28
|
+
type: "assistant",
|
|
29
|
+
message: {
|
|
30
|
+
id: `msg_${randomUUID()}`, role: "assistant", model: message.model,
|
|
31
|
+
content: message.content.map((block) => {
|
|
32
|
+
if (block.type === "toolCall") {
|
|
33
|
+
return { type: "tool_use", id: block.id, name: block.name, input: block.arguments };
|
|
34
|
+
}
|
|
35
|
+
if (block.type === "thinking") {
|
|
36
|
+
return { type: "thinking", thinking: block.thinking, ...(block.thinkingSignature
|
|
37
|
+
? { signature: block.thinkingSignature } : {}) };
|
|
38
|
+
}
|
|
39
|
+
return { type: "text", text: block.text };
|
|
40
|
+
}),
|
|
41
|
+
stop_reason: message.stopReason === "toolUse" ? "tool_use"
|
|
42
|
+
: message.stopReason === "length" ? "max_tokens" : "end_turn",
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
case "toolResult":
|
|
46
|
+
return { type: "user", message: { role: "user", content: [{
|
|
47
|
+
type: "tool_result", tool_use_id: message.toolCallId,
|
|
48
|
+
content: message.content.map(content), is_error: message.isError,
|
|
49
|
+
}] } };
|
|
50
|
+
default:
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Tool-result echoes must never become new user prompts. Remote input is text-only. */
|
|
56
|
+
export function inboundText(payload: Record<string, unknown>): string | undefined {
|
|
57
|
+
if (payload.type !== "user" || !record(payload.message)) return undefined;
|
|
58
|
+
const value = payload.message.content;
|
|
59
|
+
if (typeof value === "string") return value.trim() ? value : undefined;
|
|
60
|
+
if (!Array.isArray(value) || value.some((block) => !record(block) || block.type !== "text")) return undefined;
|
|
61
|
+
const text = value.map((block) => typeof block.text === "string" ? block.text : "").join("");
|
|
62
|
+
return text.trim() ? text : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Data-only SSE parser. Retains partial frames, including a CRLF split across chunks. */
|
|
66
|
+
export function parseSSE(buffer: string): { frames: string[]; remaining: string } {
|
|
67
|
+
const normalized = buffer.replace(/\r\n/g, "\n");
|
|
68
|
+
const parts = normalized.split("\n\n");
|
|
69
|
+
const remaining = parts.pop()!;
|
|
70
|
+
const frames = parts.map((part) => part.split("\n")
|
|
71
|
+
.filter((line) => line.startsWith("data:"))
|
|
72
|
+
.map((line) => line.slice(5).replace(/^ /, "")).join("\n")).filter(Boolean);
|
|
73
|
+
return { frames, remaining };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class RecentIds {
|
|
77
|
+
private readonly ids = new Set<string>();
|
|
78
|
+
has(id: string): boolean { return this.ids.has(id); }
|
|
79
|
+
add(id: string): void {
|
|
80
|
+
this.ids.add(id);
|
|
81
|
+
if (this.ids.size > 1024) this.ids.delete(this.ids.values().next().value!);
|
|
82
|
+
}
|
|
83
|
+
}
|
package/src/core/env.ts
CHANGED
|
@@ -14,7 +14,10 @@ export type EnvKey =
|
|
|
14
14
|
| "AGENT_BOARD_TOKEN"
|
|
15
15
|
| "AGENT_BOARD_NAME"
|
|
16
16
|
| "AGENT_BOARD_MODE"
|
|
17
|
-
| "AGENT_BOARD_SSH"
|
|
17
|
+
| "AGENT_BOARD_SSH"
|
|
18
|
+
| "PI_CLAUDE_REMOTE"
|
|
19
|
+
| "PI_CLAUDE_REMOTE_ALLOW_INBOUND"
|
|
20
|
+
| "CLAUDE_TRUSTED_DEVICE_TOKEN";
|
|
18
21
|
|
|
19
22
|
export const ENV_KEYS: { key: EnvKey; label: string; secret: boolean }[] = [
|
|
20
23
|
{ key: "ARTIFICIAL_ANALYSIS_API_KEY", label: "Artificial Analysis API key", secret: true },
|
|
@@ -23,6 +26,9 @@ export const ENV_KEYS: { key: EnvKey; label: string; secret: boolean }[] = [
|
|
|
23
26
|
{ key: "AGENT_BOARD_NAME", label: "Agent board display name", secret: false },
|
|
24
27
|
{ key: "AGENT_BOARD_MODE", label: "Agent board deployment (local|remote|external)", secret: false },
|
|
25
28
|
{ key: "AGENT_BOARD_SSH", label: "Agent board SSH host, when remote", secret: false },
|
|
29
|
+
{ key: "PI_CLAUDE_REMOTE", label: "Claude Remote auto-start (1|0)", secret: false },
|
|
30
|
+
{ key: "PI_CLAUDE_REMOTE_ALLOW_INBOUND", label: "Claude Remote input (1|0)", secret: false },
|
|
31
|
+
{ key: "CLAUDE_TRUSTED_DEVICE_TOKEN", label: "Claude trusted-device token (optional)", secret: true },
|
|
26
32
|
];
|
|
27
33
|
|
|
28
34
|
/** Reads a setting: real environment first, then the config file. */
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/** Upstream used AuthStorage.getApiKey(). In current pi, ModelRuntime owns
|
|
4
|
+
* that API and its locked OAuth refresh. Use an auth-only native runtime:
|
|
5
|
+
* same auth.json, no catalog fetches, no inference or account-pool routing.
|
|
6
|
+
* A Claude remote session must stay with the primary logged-in account.
|
|
7
|
+
*/
|
|
8
|
+
export function createTokenSource(
|
|
9
|
+
create: () => Promise<Pick<ModelRuntime, "listCredentials" | "getAuth">> = () => ModelRuntime.create({
|
|
10
|
+
modelsPath: null, refreshOnCreate: false, allowModelNetwork: false,
|
|
11
|
+
}),
|
|
12
|
+
) {
|
|
13
|
+
let runtime: ReturnType<typeof create> | undefined;
|
|
14
|
+
return async (signal: AbortSignal): Promise<string> => {
|
|
15
|
+
signal.throwIfAborted();
|
|
16
|
+
const auth = await (runtime ??= create());
|
|
17
|
+
const credentials = await auth.listCredentials({ signal });
|
|
18
|
+
if (!credentials.some((entry) => entry.providerId === "anthropic" && entry.type === "oauth")) {
|
|
19
|
+
throw new Error("Anthropic OAuth login required");
|
|
20
|
+
}
|
|
21
|
+
const result = await auth.getAuth("anthropic", { signal });
|
|
22
|
+
if (!result?.auth.apiKey || result.source !== "OAuth") throw new Error("Anthropic OAuth login required");
|
|
23
|
+
return result.auth.apiKey;
|
|
24
|
+
};
|
|
25
|
+
}
|