Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,16 @@ normally — pxpipe compresses the *request* only, never the model's output.
Recent turns stay text; the system prompt, tool docs, and older bulk history
are imaged.

### `pxpipe warp`

```bash
pxpipe warp -- claude # also: cursor-agent, codex, or a shell alias
```

Same thing without `ANTHROPIC_BASE_URL`, so `/remote-control`, claude.ai
connectors, and first-party gates keep working. Full instructions in the
dashboard.

## Offline export (no proxy)

You can render text, files, or diffs to PNG pages without running the proxy or
Expand Down
242 changes: 235 additions & 7 deletions src/core/pin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,7 @@ function filePathOf(block: string): string | undefined {
}

/** True when this message's only typed content is pin commands. */
function isCommandOnlyTurn(m: Message): boolean {
function isCommandOnlyTurn(m: Message, live = false): boolean {
if (m.role !== 'user') return false;
const blocks: ContentBlock[] = typeof m.content === 'string'
? [{ type: 'text', text: m.content }]
Expand All @@ -358,9 +358,12 @@ function isCommandOnlyTurn(m: Message): boolean {
const raw = (blk as TextBlock).text;
if (typeof raw !== 'string') return false;
// The CLAUDE.md envelope rides in the same block as the user's first typed
// line. Stripping reminders before the test would classify that turn as
// command-only and drop the project's instructions from every later request.
if (LEADING_REMINDER_RE.test(raw)) return false;
// line, so `pin` as the opening line of a Claude Code session arrives behind
// it. Answering that turn locally is safe: nothing is forwarded, and the
// client resends message 0 intact on the next request. Rewriting it is not —
// the outbound path would drop the whole block and take the project's
// instructions with it, so only the live test tolerates the envelope.
if (!live && LEADING_REMINDER_RE.test(raw)) return false;
const text = stripReminders(raw);
for (const line of text.split('\n')) {
if (!line.trim()) continue;
Expand Down Expand Up @@ -661,8 +664,26 @@ export function pinReplyText(pins: Pin[], verb: PinVerb = 'pin'): string {
* forwarded normally with the command lines merely stripped.
*/
export function isPinOnlyRequest(messages: Message[] | undefined): boolean {
const last = (messages ?? [])[messages!.length - 1];
return !!last && isCommandOnlyTurn(last);
const live = liveTurn(messages);
return !!live && isCommandOnlyTurn(live, true);
}

/**
* The turn the request is actually asking about. Claude Code appends a
* system-role message (its agent-type catalogue) *after* the user's turn, so
* the literal last element is client metadata, not work — every cc pin command
* went upstream unanswered because of it. Only `system` is skipped: a trailing
* tool_result or assistant prefill is real work and must stay on the normal
* path, which is what keeps a mid-tool-loop turn from being hijacked.
*/
function liveTurn(messages: Message[] | undefined): Message | undefined {
const list = messages ?? [];
for (let i = list.length - 1; i >= 0; i--) {
const m = list[i];
if (m && (m as { role?: string }).role === 'system') continue;
return m;
}
return undefined;
}

/**
Expand Down Expand Up @@ -704,7 +725,7 @@ export function pinCommandResponse(
}
if (!Array.isArray(req.messages) || !isPinOnlyRequest(req.messages)) return undefined;
return synthesizeReply(
pinReplyText(foldPins(req.messages), liveVerb(req.messages[req.messages.length - 1])),
pinReplyText(foldPins(req.messages), liveVerb(liveTurn(req.messages))),
typeof req.model === 'string' ? req.model : 'pxpipe',
req.stream === true,
);
Expand Down Expand Up @@ -772,3 +793,210 @@ export function synthesizeReply(
ev('message_stop', { type: 'message_stop' }),
};
}

/** Which OpenAI wire schema the reply has to imitate. */
export type OpenAIPinWire = 'chat' | 'responses';

interface OpenAIItem {
type?: string;
role?: string;
content?: unknown;
}

/** Text parts of one OpenAI message. `input_text`/`output_text` are the
* Responses spellings of Chat Completions' `text`; images and file parts carry
* no pin commands, so they drop out. */
function openAITextBlocks(content: unknown): ContentBlock[] {
if (typeof content === 'string') return [{ type: 'text', text: content }];
if (!Array.isArray(content)) return [];
const out: ContentBlock[] = [];
for (const raw of content) {
const part = raw as { type?: string; text?: unknown };
if (!part || typeof part.text !== 'string') continue;
if (part.type === undefined || part.type === 'text'
|| part.type === 'input_text' || part.type === 'output_text') {
out.push({ type: 'text', text: part.text });
}
}
return out;
}

/**
* Rewrite an OpenAI request into the Messages shape the pin folder understands.
* `instructions` and system/developer turns become the system field, which is
* where file-sourced pins live on this wire.
*/
function normalizeOpenAIRequest(
req: { messages?: unknown; input?: unknown; instructions?: unknown },
): { messages: Message[]; system?: SystemField } | undefined {
const items = Array.isArray(req.input) ? req.input
: Array.isArray(req.messages) ? req.messages
: typeof req.input === 'string' ? [{ role: 'user', content: req.input }]
: undefined;
if (!items) return undefined;
const system: Array<TextBlock | ImageBlock> = [];
if (typeof req.instructions === 'string' && req.instructions) {
system.push({ type: 'text', text: req.instructions });
}
const messages: Message[] = [];
for (const raw of items) {
if (!raw || typeof raw !== 'object') return undefined;
const item = raw as OpenAIItem;
// reasoning / function_call / function_call_output are not messages. They
// still occupy a slot: a tool result in the last position must not let an
// older pin command look like the live turn and replay its answer.
if (item.type !== undefined && item.type !== 'message') {
messages.push({ role: 'assistant', content: [] });
continue;
}
const blocks = openAITextBlocks(item.content);
if (item.role === 'system' || item.role === 'developer') {
for (const blk of blocks) system.push(blk as TextBlock);
continue;
}
messages.push({ role: item.role === 'user' ? 'user' : 'assistant', content: blocks });
}
return { messages, system: system.length > 0 ? system : undefined };
}

/** `pinCommandResponse` for the OpenAI routes. Codex talks Responses and other
* clients talk Chat Completions, so the same command has to be answered in
* whichever schema the caller used. */
export function pinCommandResponseOpenAI(
bodyIn: Uint8Array,
wire: OpenAIPinWire,
): { body: string; contentType: string } | undefined {
let req: {
messages?: unknown;
input?: unknown;
instructions?: unknown;
model?: string;
stream?: boolean;
};
try {
req = JSON.parse(new TextDecoder().decode(bodyIn));
} catch {
return undefined;
}
const norm = normalizeOpenAIRequest(req);
if (!norm || !isPinOnlyRequest(norm.messages)) return undefined;
const text = pinReplyText(
foldPins(norm.messages, norm.system),
liveVerb(norm.messages[norm.messages.length - 1]),
);
const model = typeof req.model === 'string' ? req.model : 'pxpipe';
return wire === 'responses'
? synthesizeResponsesReply(text, model, req.stream === true)
: synthesizeChatReply(text, model, req.stream === true);
}

/** Local `/v1/chat/completions` reply carrying `text`. Zero usage: nothing was
* billed, and reporting otherwise would corrupt the dashboard's accounting. */
export function synthesizeChatReply(
text: string,
model: string,
stream: boolean,
): { body: string; contentType: string } {
const id = `chatcmpl_pxpipe_pin_${Date.now().toString(36)}`;
const created = Math.floor(Date.now() / 1000);
if (!stream) {
return {
contentType: 'application/json',
body: JSON.stringify({
id,
object: 'chat.completion',
created,
model,
choices: [{
index: 0,
message: { role: 'assistant', content: text },
finish_reason: 'stop',
logprobs: null,
}],
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
}),
};
}
const chunk = (delta: unknown, finish: string | null) =>
`data: ${JSON.stringify({
id,
object: 'chat.completion.chunk',
created,
model,
choices: [{ index: 0, delta, finish_reason: finish }],
})}\n\n`;
return {
contentType: 'text/event-stream',
body: chunk({ role: 'assistant', content: '' }, null)
+ chunk({ content: text }, null)
+ chunk({}, 'stop')
+ 'data: [DONE]\n\n',
};
}

/** Local `/v1/responses` reply carrying `text`, in the event order the Responses
* API emits: clients read the final item from `response.completed`, but Codex
* renders the streamed deltas, so both have to be present. */
export function synthesizeResponsesReply(
text: string,
model: string,
stream: boolean,
): { body: string; contentType: string } {
const stamp = Date.now().toString(36);
const id = `resp_pxpipe_pin_${stamp}`;
const itemId = `msg_pxpipe_pin_${stamp}`;
const created_at = Math.floor(Date.now() / 1000);
const usage = { input_tokens: 0, output_tokens: 0, total_tokens: 0 };
const item = {
id: itemId,
type: 'message',
status: 'completed',
role: 'assistant',
content: [{ type: 'output_text', text, annotations: [] }],
};
const response = (status: string, output: unknown[]) => ({
id,
object: 'response',
created_at,
status,
model,
output,
error: null,
incomplete_details: null,
usage: status === 'completed' ? usage : null,
});
if (!stream) {
return {
contentType: 'application/json',
body: JSON.stringify(response('completed', [item])),
};
}
let seq = 0;
const ev = (type: string, data: Record<string, unknown>) =>
`event: ${type}\ndata: ${JSON.stringify({ type, sequence_number: seq++, ...data })}\n\n`;
const part = (t: string) => ({ type: 'output_text', text: t, annotations: [] });
return {
contentType: 'text/event-stream',
body:
ev('response.created', { response: response('in_progress', []) })
+ ev('response.in_progress', { response: response('in_progress', []) })
+ ev('response.output_item.added', {
output_index: 0,
item: { id: itemId, type: 'message', status: 'in_progress', role: 'assistant', content: [] },
})
+ ev('response.content_part.added', {
item_id: itemId, output_index: 0, content_index: 0, part: part(''),
})
+ ev('response.output_text.delta', {
item_id: itemId, output_index: 0, content_index: 0, delta: text,
})
+ ev('response.output_text.done', {
item_id: itemId, output_index: 0, content_index: 0, text,
})
+ ev('response.content_part.done', {
item_id: itemId, output_index: 0, content_index: 0, part: part(text),
})
+ ev('response.output_item.done', { output_index: 0, item })
+ ev('response.completed', { response: response('completed', [item]) }),
};
}
8 changes: 5 additions & 3 deletions src/core/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
chatCompletionsUrl,
openAIChatToAnthropicResponse,
} from './messages-chat-bridge.js';
import { pinCommandResponse } from './pin.js';
import { pinCommandResponse, pinCommandResponseOpenAI } from './pin.js';
import { parseGoogleModelFromPath, transformGoogleGenerateContent } from './google.js';
import { isGeminiModel } from './gemini-model-profiles.js';
import { resolveGptProfile } from './gpt-model-profiles.js';
Expand Down Expand Up @@ -1154,8 +1154,10 @@ export function createProxy(config: ProxyConfig = {}) {
// configuration, not a question. Answer it here: forwarding it would bill
// a full prefix to have the model paraphrase a list the proxy already
// holds, and the reply would be a guess about state it cannot see.
if (isMessages) {
const pinReply = pinCommandResponse(bodyIn);
if (isMessages || isOpenAIChat || isOpenAIResponses) {
const pinReply = isMessages
? pinCommandResponse(bodyIn)
: pinCommandResponseOpenAI(bodyIn, isOpenAIResponses ? 'responses' : 'chat');
if (pinReply) {
return new Response(pinReply.body, {
status: 200,
Expand Down
17 changes: 16 additions & 1 deletion src/dashboard/fragments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ export function renderSessionSummaryFragment(s: StatsPayload): string {
`<div class="hero hero-empty">` +
`<div class="hero-eyebrow">Since start</div>` +
`<div class="hero-headline">Warming up…</div>` +
`<div class="hero-sub">Point Claude Code at this proxy and send a message. The moment a request flows through, your running savings show up right here.</div>` +
`<div class="hero-sub">Point Claude Code at this proxy with <code>ANTHROPIC_BASE_URL</code>, or launch it with <code>pxpipe warp -- claude</code> to keep <code>/remote-control</code> and claude.ai connectors working. Send a message and your running savings show up right here.</div>` +
`</div>`
);
}
Expand Down Expand Up @@ -1225,6 +1225,21 @@ export function renderPage(port: number): string {
</div>
</header>

<details class="models-collapse">
<summary class="models-summary">Connect an agent <span class="hint">warp launches any CLI through this proxy · pin keeps instructions last in the request</span></summary>
<p>Warp starts the agent with the proxy already wired, no env or config edits:</p>
<pre>pxpipe warp -- claude
pxpipe warp -- codex
pxpipe warp -- cursor-agent</pre>
<p>Aliases work too (<code>pxpipe warp -- pp</code>), and <code>--route pattern=http://host:port</code> adds routes beyond <code>api.anthropic.com</code>. Without warp, point the agent at <code>ANTHROPIC_BASE_URL=http://127.0.0.1:${port}</code> yourself.</p>
<p>Pin instructions from inside the session — they get moved to the end of every request, where the model actually reads them:</p>
<pre>@pxpipe pin be concise, no walls of text
@pxpipe unpin 2
@pxpipe unpin all</pre>
<p><code>@pxpipe pin</code> with no text lists what is pinned.</p>
<p>A <code>@pxpipe pin …</code> line in your global or project <code>CLAUDE.md</code> (<code>AGENTS.md</code> under Codex / OpenCode) is relocated the same way, on every session, with no typing. Those are file-backed, so <code>unpin</code> and <code>unpin all</code> never touch them — edit the file to remove one.</p>
</details>

<details class="models-collapse">
<summary class="models-summary">Image model scope <span class="hint">Fable 5 and Gemini 3.6 Flash by default · expand to experiment with other families</span></summary>
<div class="models-warning">⚠ Image compression is validated for Fable 5 and Gemini 3.6 Flash — other families can use <strong>more</strong> tokens, not less. Opt in only for deliberate experiments.</div>
Expand Down
Loading
Loading