Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
- Changed automatic harness refinement to be enabled by default while keeping `autoRefine.enabled: false` as the opt-out.
- Fixed non-numeric `autoRefine.turnInterval` and `autoRefine.cooldownMs` settings falling back to defaults instead of silently enabling a noisy auto-refine loop.
- Fixed all session-resume entry points to share a searchable full-screen picker, stream results while loading, and support renaming ([ENG-4513](https://linear.app/primeintellect/issue/ENG-4513/resume-in-agents-view-is-broken)).
- Fixed self-updates losing restored daemon sessions to a socket cleanup race and leaving open session or agents-view windows disconnected.

## [0.2.7] - 2026-07-08

Expand Down
20 changes: 14 additions & 6 deletions packages/coding-agent/src/cli/daemon-launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,28 +107,36 @@ export class StaleDaemonError extends Error {
}
}

async function waitForDaemonGone(socketPath: string, timeoutMs = 5000): Promise<boolean> {
async function waitForDaemonGone(socketPath: string, timeoutMs = 5000, requireSocketCleanup = false): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (!(await canConnectToDaemon(socketPath, 250))) {
if (
!(await canConnectToDaemon(socketPath, 250)) &&
(!requireSocketCleanup || process.platform === "win32" || !existsSync(socketPath))
) {
Comment thread
cursor[bot] marked this conversation as resolved.
return true;
}
await delay(25);
}
return false;
// A daemon can exit without removing its Unix socket (for example, after a crash
// during shutdown). Once the cleanup grace has elapsed, a non-listening socket
// is safe for the replacement daemon's guarded startup path to reclaim.
return requireSocketCleanup && !(await canConnectToDaemon(socketPath, 250));
}

export async function shutdownDaemonAndWait(socketPath: string): Promise<boolean> {
export async function shutdownDaemonAndWait(socketPath: string, timeoutMs = 5000): Promise<boolean> {
const client = new DaemonClient(socketPath);
let shutdownAccepted = false;
try {
await client.connect(1000);
await client.request({ type: "shutdown" }).catch(() => undefined);
const response = await client.request({ type: "shutdown" });
shutdownAccepted = response.success;
} catch {
// A connect failure isn't treated as "gone"; waitForDaemonGone is the source of truth.
} finally {
client.close();
}
return waitForDaemonGone(socketPath);
return waitForDaemonGone(socketPath, timeoutMs, shutdownAccepted);
}

// activeSessions is undefined when the daemon is reachable but its sessions couldn't
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ type DistributiveOmit<T, K extends keyof T> = T extends unknown ? Omit<T, K> : n
type DaemonCommandBody = DistributiveOmit<DaemonCommand, "id">;

export const DAEMON_REFINE_REQUEST_TIMEOUT_MS = 10 * 60 * 1000;
const UPDATE_RECONNECT_TIMEOUT_MS = 120000;
const UPDATE_RECONNECT_RETRY_MS = 100;

function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

export interface DaemonAgentConnectionOptions {
closeClientOnDispose?: boolean;
Expand All @@ -86,6 +92,11 @@ export class DaemonAgentConnection implements AgentConnection {
private lastEventSequence: number | undefined;
private latestSnapshot: AgentConnectionSnapshot | undefined;
private latestSnapshotIsFresh = false;
private attachedSessionId: string | undefined;
private attachedSessionFile: string | undefined;
private updateRestartPending = false;
private updateReconnectPromise?: Promise<void>;
private disposed = false;

constructor(
private readonly client: DaemonClient,
Expand All @@ -96,6 +107,10 @@ export class DaemonAgentConnection implements AgentConnection {
void this.handleDaemonMessage(message);
});
this.unsubscribeDaemonClose = this.client.onClose((error) => {
if (this.updateRestartPending && !this.disposed) {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
void this.reconnectAfterUpdate();
Comment thread
cursor[bot] marked this conversation as resolved.
return;
}
Comment thread
cursor[bot] marked this conversation as resolved.
void this.emit({ type: "closed", error: error.message });
});
}
Expand Down Expand Up @@ -132,6 +147,9 @@ export class DaemonAgentConnection implements AgentConnection {
},
});
this.activeSessionId = getAttachActiveSessionId(result);
const summary = "snapshot" in result ? result.snapshot.summary : result;
this.attachedSessionId = summary.sessionId;
this.attachedSessionFile = summary.sessionFile;
this.lastEventSequence = maxEventSequence(this.lastEventSequence, getAttachLastEventSequence(result));
if ("snapshot" in result) {
this.latestSnapshot = mapDaemonAttachSnapshot(result);
Expand Down Expand Up @@ -683,6 +701,8 @@ export class DaemonAgentConnection implements AgentConnection {
}

async dispose(): Promise<void> {
this.disposed = true;
this.updateRestartPending = false;
Comment thread
cursor[bot] marked this conversation as resolved.
this.unsubscribeDaemonMessages();
this.unsubscribeDaemonClose();
await this.requestOk({ type: "detach", activeSessionId: this.activeSessionId }).catch(() => undefined);
Expand Down Expand Up @@ -756,10 +776,78 @@ export class DaemonAgentConnection implements AgentConnection {
return;
}
if (message.type === "session_closed") {
if (message.reason === "update") {
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
this.updateRestartPending = true;
return;
}
await this.emit({ type: "closed", error: message.reason });
}
}

private reconnectAfterUpdate(): Promise<void> {
if (this.updateReconnectPromise) {
return this.updateReconnectPromise;
}
const reconnectPromise = this.restoreConnectionAfterUpdate()
.catch(async (error: unknown) => {
if (!this.disposed) {
await this.emit({
type: "closed",
error: `Failed to reconnect after update: ${error instanceof Error ? error.message : String(error)}`,
});
}
Comment thread
cursor[bot] marked this conversation as resolved.
})
.finally(() => {
if (this.updateReconnectPromise === reconnectPromise) {
this.updateReconnectPromise = undefined;
}
});
this.updateReconnectPromise = reconnectPromise;
return reconnectPromise;
Comment thread
cursor[bot] marked this conversation as resolved.
}

private async restoreConnectionAfterUpdate(): Promise<void> {
const sessionId = this.attachedSessionId;
const sessionFile = this.attachedSessionFile;
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
if (!sessionId && !sessionFile) {
throw new Error("the previous session identity is unavailable");
}
const deadline = Date.now() + UPDATE_RECONNECT_TIMEOUT_MS;
let lastError: unknown;
while (!this.disposed && Date.now() < deadline) {
try {
await this.client.reconnect(1000);
const response = await this.client.request({ type: "list" }, 30000);
if (!response.success) {
throw deserializeDaemonError(response);
}
const sessions = readSessionSummaries(response.data);
const restored = sessions.find(
(summary) =>
summary.activeSessionId !== undefined &&
((sessionFile !== undefined && summary.sessionFile === sessionFile) ||
(sessionId !== undefined && summary.sessionId === sessionId)),
);
if (restored?.activeSessionId) {
this.activeSessionId = restored.activeSessionId;
this.lastEventSequence = undefined;
await this.attach();
const snapshot = await this.getInitialSnapshot();
this.updateRestartPending = false;
await this.emit({ type: "session_replaced", state: snapshot.state, messages: snapshot.messages });
return;
}
} catch (error) {
lastError = error;
}
await delay(UPDATE_RECONNECT_RETRY_MS);
}
if (this.disposed) {
return;
}
throw lastError ?? new Error("the restored session did not become available");
}

private isMessageForActiveSession(message: DaemonOutbound): boolean {
if (!("activeSessionId" in message)) {
return false;
Expand Down Expand Up @@ -788,6 +876,13 @@ export class DaemonAgentConnection implements AgentConnection {
}
}

function readSessionSummaries(value: unknown): SessionSummary[] {
if (!value || typeof value !== "object" || !Array.isArray((value as { sessions?: unknown }).sessions)) {
throw new Error("Daemon returned an invalid session list response");
}
return (value as { sessions: SessionSummary[] }).sessions;
}

function getAttachActiveSessionId(result: SessionSummary | DaemonAttachResult): string {
if ("snapshot" in result) {
return result.activeSessionId;
Expand Down
77 changes: 62 additions & 15 deletions packages/coding-agent/src/modes/agents-view/agents-view-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ import {
} from "./agents-view-state.js";

const POLL_INTERVAL_MS = 1000;
const RECONNECT_RETRY_MS = 1000;
const EXIT_HINT_DURATION_MS = 2000;
const DELETE_CONFIRM_DURATION_MS = 2000;
const STATUS_MESSAGE_DURATION_MS = 4500;
Expand Down Expand Up @@ -376,6 +377,8 @@ class AgentsViewMode implements Component, Focusable {
private readonly fullscreenDock: Component;
private readonly keybindings: KeybindingsManager;
private client: DaemonClient | undefined;
private unsubscribeClientClose: (() => void) | undefined;
private reconnectPromise: Promise<void> | undefined;
private resolveRun: ((result: AgentsViewRunResult) => void) | undefined;
private pollTimer: NodeJS.Timeout | undefined;
private animationTimer: NodeJS.Timeout | undefined;
Expand Down Expand Up @@ -470,6 +473,7 @@ class AgentsViewMode implements Component, Focusable {
async run(): Promise<AgentsViewRunResult> {
this.client = new DaemonClient(this.options.socketPath);
await this.client.connect();
this.subscribeToClientClose(this.client);

this.ui.addChild(this);
this.ui.setFocus(this);
Expand Down Expand Up @@ -1725,29 +1729,37 @@ class AgentsViewMode implements Component, Focusable {
}

private async refreshSessions(): Promise<void> {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Comment thread
kevinjosethomas marked this conversation as resolved.
if (this.reconnectPromise) {
return;
}
const client = this.requireClient();
try {
const response = await client.request(createAgentsViewListCommand());
const data = requireDaemonData(response);
const sessions = expectSessionList(data);
this.lastListedSummaries = sessions;
const visibleSessions = sessions.filter((summary) =>
shouldShowAgentsViewSession(summary, this.inactiveAgentIdentities.has(getSummaryIdentity(summary))),
);
this.lastVisibleSummaries = this.withPendingDeleteSession(visibleSessions);
this.rows = buildAgentsViewRows(
this.lastVisibleSummaries,
this.expandedSubagentParents,
this.programShownParents,
);
this.applyPendingAncestorExpansion();
this.restoreSelection();
this.ui.requestRender();
this.applySessionList(expectSessionList(data));
} catch (error) {
this.setStatusMessage(formatError("Failed to refresh agents", error));
if (!this.reconnectPromise) {
this.setStatusMessage(formatError("Failed to refresh agents", error));
}
Comment thread
cursor[bot] marked this conversation as resolved.
}
}

private applySessionList(sessions: SessionSummary[]): void {
this.lastListedSummaries = sessions;
const visibleSessions = sessions.filter((summary) =>
shouldShowAgentsViewSession(summary, this.inactiveAgentIdentities.has(getSummaryIdentity(summary))),
);
this.lastVisibleSummaries = this.withPendingDeleteSession(visibleSessions);
this.rows = buildAgentsViewRows(
this.lastVisibleSummaries,
this.expandedSubagentParents,
this.programShownParents,
);
this.applyPendingAncestorExpansion();
this.restoreSelection();
this.ui.requestRender();
}

private withPendingDeleteSession(sessions: readonly SessionSummary[]): SessionSummary[] {
const pending = this.pendingDeleteAgent;
if (!pending) {
Expand Down Expand Up @@ -1824,12 +1836,47 @@ class AgentsViewMode implements Component, Focusable {
flushFullscreen: false,
});
stopThemeWatcher();
this.unsubscribeClientClose?.();
this.unsubscribeClientClose = undefined;
this.client?.close();
this.client = undefined;
this.resolveRun?.(result);
this.resolveRun = undefined;
}

private subscribeToClientClose(client: DaemonClient): void {
this.unsubscribeClientClose?.();
this.unsubscribeClientClose = client.onClose(() => {
if (this.stopped || client !== this.client || this.reconnectPromise) {
return;
}
this.setStatusMessage("Daemon restarted; reconnecting...", { sticky: true });
const reconnectPromise = this.reconnectClient(client).finally(() => {
if (this.reconnectPromise === reconnectPromise) {
this.reconnectPromise = undefined;
}
});
this.reconnectPromise = reconnectPromise;
});
}

private async reconnectClient(client: DaemonClient): Promise<void> {
while (!this.stopped && client === this.client) {
try {
await client.reconnect(1000);
const response = await client.request(createAgentsViewListCommand());
const data = requireDaemonData(response);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
const sessions = expectSessionList(data);
this.setStatusMessage("Reconnected after daemon restart", { render: false });
this.applySessionList(sessions);
return;
Comment thread
cursor[bot] marked this conversation as resolved.
} catch {
// Keep the existing rows visible and retry until the view exits or the daemon returns.
}
await new Promise<void>((resolve) => setTimeout(resolve, RECONNECT_RETRY_MS));
}
Comment thread
kevinjosethomas marked this conversation as resolved.
}

private requireClient(): DaemonClient {
if (!this.client) {
throw new Error("Agents view daemon client is not connected");
Expand Down
20 changes: 20 additions & 0 deletions packages/coding-agent/src/modes/daemon/daemon-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export class DaemonClient {
>();
private requestId = 0;
private helloMessage?: DaemonHello;
private reconnectPromise?: Promise<void>;
private readonly helloWaiters = new Set<{
resolve: (hello: DaemonHello) => void;
reject: (error: Error) => void;
Expand Down Expand Up @@ -75,6 +76,7 @@ export class DaemonClient {
throw new Error("Daemon client is already connected");
}

this.helloMessage = undefined;
const socket = createConnection(this.socketPath);
this.socket = socket;
this.detachReader = attachJsonlLineReader(socket, (line) => this.handleLine(line));
Expand Down Expand Up @@ -108,6 +110,24 @@ export class DaemonClient {
socket.on("close", () => this.notifyClosed(socket, new Error("Daemon socket closed")));
}

async reconnect(timeoutMs = 3000): Promise<void> {
if (this.reconnectPromise) {
return this.reconnectPromise;
}
if (this.socket && !this.socket.destroyed) {
return;
}
const reconnectPromise = this.connect(timeoutMs);
this.reconnectPromise = reconnectPromise;
try {
await reconnectPromise;
} finally {
if (this.reconnectPromise === reconnectPromise) {
this.reconnectPromise = undefined;
}
}
}

onMessage(listener: DaemonClientMessageListener): () => void {
this.listeners.add(listener);
return () => {
Expand Down
Loading
Loading