Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
e23ae1b
feat: add Pi Coding Agent support (#862)
zhushanwen321 Jun 18, 2026
0bf17b8
fix(web): use button CSS variables for Retry button on error screen (…
heavygee Jun 18, 2026
26d3c2e
fix(hub+cli): defer mergeSessions on cursor ACP reopen until session/…
heavygee Jun 18, 2026
b3add07
fix: detect stale PID in runner state after abnormal shutdown (#931)
KorenKrita Jun 18, 2026
4bc3393
fix(cli): stateful MCP HTTP transport for display_image (#944)
heavygee Jun 18, 2026
fc8c32e
fix: reliable generated-image display + stop client remount storm (#9…
swear01 Jun 18, 2026
a858256
fix(web,hub): surface inactive-session error on text-only send (close…
heavygee Jun 18, 2026
4e4043d
fix(claude): flush OutgoingMessageQueue before consuming next user tu…
swear01 Jun 18, 2026
3dfbd61
fix(runner): surface dangling-symlink errors instead of misleading EE…
heavygee Jun 18, 2026
a2862a3
docs(installation): add KillMode=process to runner systemd unit (clos…
heavygee Jun 18, 2026
78155a9
perf(web): add staleTime to useSession to suppress focus/mount refetc…
heavygee Jun 18, 2026
5f27abd
feat(web): in-app PWA update prompt when new service worker is availa…
heavygee Jun 18, 2026
ce67823
feat(web,hub): rich hover tooltips on session-list attention indicato…
heavygee Jun 18, 2026
dfb1805
feat(web): OLED Black theme + per-appearance custom colors (#937)
swear01 Jun 18, 2026
2643f17
feat(web): Web Share Target -> Android system share sheet integration…
heavygee Jun 18, 2026
a8e08e8
feat(web): add download button to file viewer (#926)
swear01 Jun 18, 2026
22bf7e0
fix(web): persist file explorer expanded tree and scroll position acr…
swear01 Jun 18, 2026
f5c0ef2
fix: active-only session filter + paginated "Show N more" (closes #90…
swear01 Jun 18, 2026
8f3ea10
fix(web): mechanical repair of GFM tables with off-by-one separator r…
heavygee Jun 18, 2026
d1a686f
fix(cursor): map base-only CLI sku to fast=false to avoid silent vari…
swear01 Jun 18, 2026
bfd0f4a
fix(web): keep Codex restart control clear of close button (#880)
DolphinZZZZZ Jun 18, 2026
02a0aa6
fix(cursor): surface agent errors with warning styling in web UI (#871)
swear01 Jun 18, 2026
b1910b6
feat(web): session header files and outline view toggles (#952)
heavygee Jun 19, 2026
a0259b5
feat(web): drag-and-drop files onto chat panel to add as attachments …
swear01 Jun 19, 2026
2ab3b39
fix(hub,cli): four hub-restart-cascade cleanup bugs (#913 #914 #916 #…
heavygee Jun 19, 2026
b0eb4d5
feat(cli): cross-flavor inline image display via MCP and ACP
heavygee Jun 19, 2026
8b6afb9
fix(web): render generated-image cards reliably in chat
heavygee Jun 19, 2026
cb340cc
feat(cli+web): display_video MCP for inline mp4/webm (#956)
heavygee Jun 20, 2026
75ed607
feat(cli+web): cross-flavor display_video parity with images (#956)
heavygee Jun 20, 2026
7c3f9a6
fix(scripts): resolve MCP SDK from workspace root in hapi-display-image
heavygee Jun 20, 2026
a33b25f
fix(cli): ACP image ordering and inline media source provenance
heavygee Jun 22, 2026
cd9073d
fix(cli+web): address PR #958 review Majors on media order and stale …
heavygee Jun 22, 2026
5fd8b42
fix(cli): await ACP queue after late-drain before turn_complete
heavygee Jun 22, 2026
67ca0b8
fix(scripts): route AVIF ftyp brands to display_image in helper
heavygee Jun 22, 2026
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,6 @@ cli/npm/main/
test-results/
playwright-report/
e2e-output/
.xyz-harness
.agents/
.pi/
12 changes: 7 additions & 5 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,4 @@
"@types/parse-path": "7.0.3"
},
"packageManager": "bun@1.3.14"
}
}
90 changes: 90 additions & 0 deletions cli/src/agent/backends/acp/AcpMessageHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url';
import type { AgentMessage } from '@/agent/types';
import { AcpMessageHandler } from './AcpMessageHandler';
import { ACP_SESSION_UPDATE_TYPES } from './constants';
import { clearGeneratedImages } from '@/modules/common/generatedImages';

function getToolResult(messages: AgentMessage[], id: string): Extract<AgentMessage, { type: 'tool_result' }> {
const result = messages.find((message): message is Extract<AgentMessage, { type: 'tool_result' }> =>
Expand Down Expand Up @@ -2093,4 +2094,93 @@ describe('AcpMessageHandler', () => {
});
});
});

it('emits generated_image agent messages from ACP image content blocks', async () => {
const messages: AgentMessage[] = [];
const handler = new AcpMessageHandler((message) => messages.push(message));
const pngHeader = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x00]);

handler.handleUpdate({
sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentMessageChunk,
content: {
type: 'image',
mimeType: 'image/png',
data: pngHeader.toString('base64')
}
});

await vi.waitFor(() => {
expect(messages.some((message) => message.type === 'generated_image')).toBe(true);
});

const imageMessage = messages.find(
(message): message is Extract<AgentMessage, { type: 'generated_image' }> =>
message.type === 'generated_image'
);
expect(imageMessage?.mimeType).toBe('image/png');
expect(imageMessage?.fileName).toBeTruthy();
expect(imageMessage?.imageId).toBeTruthy();
expect(imageMessage?.source).toEqual({ ingress: 'acp' });
clearGeneratedImages();
});

it('emits generated_image before later tool_call when image registration is async', async () => {
const messages: AgentMessage[] = [];
const handler = new AcpMessageHandler((message) => messages.push(message));
const pngHeader = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x00]);

await handler.handleUpdate({
sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentMessageChunk,
content: {
type: 'image',
mimeType: 'image/png',
data: pngHeader.toString('base64'),
},
});
await handler.handleUpdate({
sessionUpdate: ACP_SESSION_UPDATE_TYPES.toolCall,
toolCallId: 'call-after-image',
title: 'Read',
kind: 'read',
status: 'in_progress',
});

const imageIndex = messages.findIndex((message) => message.type === 'generated_image');
const toolIndex = messages.findIndex((message) => message.type === 'tool_call');
expect(imageIndex).toBeGreaterThanOrEqual(0);
expect(toolIndex).toBeGreaterThan(imageIndex);
clearGeneratedImages();
});

it('emits buffered text before generated_image when text precedes an ACP image block', async () => {
const messages: AgentMessage[] = [];
const handler = new AcpMessageHandler((message) => messages.push(message), { flavor: 'cursor' });
const pngHeader = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x00]);

await handler.handleUpdate({
sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentMessageChunk,
content: { type: 'text', text: 'Here is the screenshot:' }
});
await handler.handleUpdate({
sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentMessageChunk,
content: {
type: 'image',
mimeType: 'image/png',
data: pngHeader.toString('base64')
}
});

await vi.waitFor(() => {
expect(messages.some((message) => message.type === 'generated_image')).toBe(true);
});

const textIndex = messages.findIndex((message) => message.type === 'text');
const imageIndex = messages.findIndex((message) => message.type === 'generated_image');
expect(textIndex).toBeGreaterThanOrEqual(0);
expect(imageIndex).toBeGreaterThan(textIndex);
if (messages[imageIndex]?.type === 'generated_image') {
expect(messages[imageIndex].source).toEqual({ ingress: 'acp', flavor: 'cursor' });
}
clearGeneratedImages();
});
});
47 changes: 44 additions & 3 deletions cli/src/agent/backends/acp/AcpMessageHandler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type { AgentMessage, PlanItem } from '@/agent/types';
import { randomUUID } from 'node:crypto';
import { logger } from '@/ui/logger';
import type { AgentMessage, PlanItem } from '@/agent/types';
import { registerGeneratedImageFromAcpBlock } from '@/modules/common/generatedImages';
import type { InlineMediaSource } from '@/modules/common/inlineMediaSource';
import { asString, isObject } from '@hapi/protocol';
import { deriveToolNameWithSource, isPlaceholderToolName } from '@/agent/utils';
import { parseRateLimitText } from '@/agent/rateLimitParser';
Expand Down Expand Up @@ -381,7 +384,10 @@ export class AcpMessageHandler {
private lastReasoningSnapshotText = '';
private reasoningSnapshotEmitted = false;

constructor(private readonly onMessage: (message: AgentMessage) => void) {}
constructor(
private readonly onMessage: (message: AgentMessage) => void,
private readonly options?: { flavor?: string }
) {}

/**
* Emits any buffered assistant text as a single message and clears the
Expand Down Expand Up @@ -528,7 +534,7 @@ export class AcpMessageHandler {
this.reasoningSnapshotEmitted = false;
}

handleUpdate(update: unknown): void {
async handleUpdate(update: unknown): Promise<void> {
if (!isObject(update)) return;
const updateType = asString(update.sessionUpdate);
if (!updateType) return;
Expand All @@ -554,6 +560,12 @@ export class AcpMessageHandler {

if (updateType === ACP_SESSION_UPDATE_TYPES.agentMessageChunk) {
const content = update.content;
if (isObject(content) && content.type === 'image') {
this.flushReasoning();
this.flushText();
await this.emitGeneratedImageFromAcpContent(content);
return;
}
const text = extractTextContent(content);
if (text) {
// Check once whether the buffered text is a prefix of this
Expand Down Expand Up @@ -629,6 +641,35 @@ export class AcpMessageHandler {
}
}

private async emitGeneratedImageFromAcpContent(content: Record<string, unknown>): Promise<void> {
try {
const image = await registerGeneratedImageFromAcpBlock(content);
if (!image) {
return;
}
this.onMessage({
type: 'generated_image',
imageId: image.id,
fileName: image.fileName,
mimeType: image.mimeType,
source: this.buildAcpInlineMediaSource(),
});
} catch (error) {
logger.debug(
'[AcpMessageHandler] Failed to register ACP image block:',
error instanceof Error ? error.message : String(error)
);
}
}

private buildAcpInlineMediaSource(): InlineMediaSource {
const source: InlineMediaSource = { ingress: 'acp' };
if (this.options?.flavor) {
source.flavor = this.options.flavor;
}
return source;
}

private handleToolCall(update: Record<string, unknown>): void {
const toolCallId = asString(update.toolCallId);
if (!toolCallId) return;
Expand Down
30 changes: 26 additions & 4 deletions cli/src/agent/backends/acp/AcpSdkBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export class AcpSdkBackend implements AgentBackend {
private promptUsageCallback: ((msg: AgentMessage) => void) | null = null;
private usageUpdateListener: ((msg: AgentMessage) => void) | null = null;
private lastForwardedUsageUpdate: AcpUsageUpdate | null = null;
private sessionUpdateQueue: Promise<void> = Promise.resolve();

/** Retry configuration for ACP initialization */
private static readonly INIT_RETRY_OPTIONS = {
Expand Down Expand Up @@ -101,7 +102,12 @@ export class AcpSdkBackend implements AgentBackend {
private static readonly LATE_FLUSH_QUIET_PERIOD_MS = 250;
private static readonly LATE_FLUSH_WINDOW_MS = 6000;

constructor(private readonly options: { command: string; args?: string[]; env?: Record<string, string> }) {}
constructor(private readonly options: {
command: string;
args?: string[];
env?: Record<string, string>;
flavor?: AgentFlavor;
}) {}

async initialize(): Promise<void> {
if (this.transport) return;
Expand Down Expand Up @@ -394,8 +400,9 @@ export class AcpSdkBackend implements AgentBackend {
AcpSdkBackend.PRE_PROMPT_UPDATE_QUIET_PERIOD_MS,
AcpSdkBackend.PRE_PROMPT_UPDATE_DRAIN_TIMEOUT_MS
);
await this.sessionUpdateQueue;
this.messageHandler?.drainBuffers();
this.messageHandler = new AcpMessageHandler(onUpdate);
this.messageHandler = new AcpMessageHandler(onUpdate, { flavor: this.options.flavor });
this.isProcessingMessage = true;
this.lastSessionUpdateAt = Date.now();
this.latestUsageUpdate = null;
Expand All @@ -419,12 +426,17 @@ export class AcpSdkBackend implements AgentBackend {
AcpSdkBackend.UPDATE_QUIET_PERIOD_MS,
AcpSdkBackend.UPDATE_DRAIN_TIMEOUT_MS
);
await this.sessionUpdateQueue;
this.messageHandler?.drainBuffers();
// Block here until the model truly stops streaming straggler
// chunks (or LATE_FLUSH_WINDOW_MS elapses), so turn_complete and
// the launcher's ready signal only fire once every chunk has been
// emitted to this turn's onUpdate.
await this.drainLateBuffers();
// Late window can enqueue async image registration; drain again
// before turn_complete so generated_image precedes turn boundary.
await this.sessionUpdateQueue;
this.messageHandler?.drainBuffers();
try {
const latestUsageUpdate = this.readLatestUsageUpdate();
if (promptUsage) {
Expand Down Expand Up @@ -537,6 +549,7 @@ export class AcpSdkBackend implements AgentBackend {

async disconnect(): Promise<void> {
if (!this.transport) return;
await this.sessionUpdateQueue;
this.messageHandler?.drainBuffers();
this.messageHandler = null;
this.activeSessionId = null;
Expand All @@ -555,8 +568,17 @@ export class AcpSdkBackend implements AgentBackend {
}
this.lastSessionUpdateAt = Date.now();
const update = params.update;
this.captureUsageUpdate(update);
this.messageHandler?.handleUpdate(update);
this.sessionUpdateQueue = this.sessionUpdateQueue
.then(async () => {
this.captureUsageUpdate(update);
await this.messageHandler?.handleUpdate(update);
})
.catch((error) => {
logger.debug(
'[AcpSdkBackend] session update failed:',
error instanceof Error ? error.message : String(error)
);
});
}

private captureUsageUpdate(update: unknown): void {
Expand Down
1 change: 1 addition & 0 deletions cli/src/agent/localHandoff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ describe('registerLocalHandoffHandler', () => {
const lifecycle = {
setArchiveReason: vi.fn(),
setSessionEndReason: vi.fn(),
hasExplicitSessionEndReason: vi.fn(() => false),
cleanupAndExit: vi.fn(async () => {})
}

Expand Down
31 changes: 31 additions & 0 deletions cli/src/agent/messageConverter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,18 @@ describe('convertAgentMessage', () => {
});
});

it('converts agent errors into error wire payloads', () => {
const converted = convertAgentMessage({
type: 'error',
message: 'Cursor Agent failed: authentication required'
});

expect(converted).toEqual({
type: 'error',
message: 'Cursor Agent failed: authentication required'
});
});

it('converts usage messages into token_count payloads', () => {
const converted = convertAgentMessage({
type: 'usage',
Expand Down Expand Up @@ -77,4 +89,23 @@ describe('convertAgentMessage', () => {
}
});
});

it('converts generated_image messages into generated-image wire payloads', () => {
const converted = convertAgentMessage({
type: 'generated_image',
imageId: 'img-1',
fileName: 'inline.png',
mimeType: 'image/png',
source: { ingress: 'mcp', toolName: 'display_image' },
});

expect(converted).toMatchObject({
type: 'generated-image',
imageId: 'img-1',
fileName: 'inline.png',
mimeType: 'image/png',
source: { ingress: 'mcp', toolName: 'display_image' },
});
expect(converted && 'id' in converted && typeof converted.id === 'string').toBe(true);
});
});
Loading
Loading