Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
20 changes: 20 additions & 0 deletions packages/ai/scripts/generate-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,25 @@ function mergePrimeInferenceModels(
return Array.from(models.values());
}

function refreshPrimeInferenceAliasLimits(
snapshotModels: Model<"openai-completions">[],
catalogModels: Model<"openai-completions">[],
): Model<"openai-completions">[] {
const liveModels = new Map(catalogModels.map((model) => [model.id.toLowerCase(), model]));
return snapshotModels.map((model) => {
const canonicalId = PRIME_INFERENCE_OPENROUTER_ALIASES[model.id.toLowerCase()];
const canonical = canonicalId ? liveModels.get(canonicalId) : undefined;
if (!canonical) {
return model;
}
return {
...model,
contextWindow: canonical.contextWindow,
maxTokens: canonical.maxTokens,
};
});
}

function includesCatalogCapability(value: unknown, capabilities: readonly string[]): boolean {
if (!Array.isArray(value)) {
return false;
Expand Down Expand Up @@ -633,6 +652,7 @@ async function fetchPrimeInferenceModels(): Promise<Model<"openai-completions">[
(model) => liveIds.has(model.id.toLowerCase()) || model.id.toLowerCase().startsWith("internal/"),
);
}
snapshotModels = refreshPrimeInferenceAliasLimits(snapshotModels, catalogModels);
const models = mergePrimeInferenceModels(snapshotModels, catalogModels);
console.log(`Loaded ${models.length} Prime Inference models (${catalogModels.length} from the live catalog)`);
return models;
Expand Down
3 changes: 3 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

- Added `/btw` and `/side` for one-turn inline side questions that use the current context without changing the main session ([ENG-4509](https://linear.app/primeintellect/issue/ENG-4509/add-btw-and-side-side-question-flows)).
- Changed the new-chat splash to show only version, model, and cwd metadata and rotate among five example prompts.
- Fixed self-updates losing restored daemon sessions to a socket cleanup race and leaving open session or agents-view windows disconnected.
- Changed daemon connection errors to report the failed operation, session identity, recovery steps, socket, and diagnostic log instead of raw protocol reasons.
- Fixed Agents View retrying after an intentional daemon shutdown instead of stopping with restart guidance.
- Fixed stale heartbeat jobs reopening archived, deleted, or concurrently terminated sessions ([ENG-4519](https://linear.app/primeintellect/issue/ENG-4519/heartbeats-rebirth-sessions-that-were-previously-killed)).

## [0.2.8] - 2026-07-09
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
@@ -1,20 +1,22 @@
import { randomUUID } from "node:crypto";
import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core";
import type { ImageContent, Transport } from "@earendil-works/pi-ai";
import { getAgentLogPath, getDaemonLogPath } from "../../config.js";
import type { CompactionResult } from "../../core/compaction/index.js";
import type { ContextTreeNode } from "../../core/context-tree.js";
import type { AgentCronJob, AgentHeartbeatUpdateAction } from "../../core/cron-jobs.js";
import type { RefinementResult } from "../../core/refinement/index.js";
import type { DeleteSessionFileResult } from "../../core/session-file-actions.js";
import type { SessionStats } from "../../core/session-stats.js";
import type { DaemonClient } from "../daemon/daemon-client.js";
import { type DaemonClient, getDaemonSocketCloseReason } from "../daemon/daemon-client.js";
import { deserializeDaemonError } from "../daemon/daemon-errors.js";
import {
collectDaemonClientEnv,
type DaemonAttachResult,
type DaemonCommand,
type DaemonOutbound,
type DaemonReplayInfo,
type DaemonSessionClosedReason,
type DaemonSessionSnapshot,
isUnknownDaemonCommandError,
} from "../daemon/daemon-protocol.js";
Expand Down Expand Up @@ -61,6 +63,51 @@ 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;
const updateTransportReconnects = new WeakMap<DaemonClient, Promise<void>>();

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

function formatErrorSentence(error: unknown): string {
const message = (error instanceof Error ? error.message : String(error)).trim();
if (!message) {
return "Unknown daemon error.";
}
return /[.!?]$/.test(message) ? message : `${message}.`;
}

function reconnectDaemonTransportAfterUpdate(client: DaemonClient): Promise<void> {
const existing = updateTransportReconnects.get(client);
if (existing) {
return existing;
}
const reconnectPromise = Promise.resolve()
.then(async () => {
client.disconnectForReconnect("update");
const deadline = Date.now() + UPDATE_RECONNECT_TIMEOUT_MS;
let lastError: unknown;
while (Date.now() < deadline) {
try {
await client.reconnect(1000);
return;
} catch (error) {
lastError = error;
}
await delay(UPDATE_RECONNECT_RETRY_MS);
}
throw lastError ?? new Error("the updated daemon did not become available");
})
.finally(() => {
if (updateTransportReconnects.get(client) === reconnectPromise) {
updateTransportReconnects.delete(client);
}
});
updateTransportReconnects.set(client, reconnectPromise);
return reconnectPromise;
}

export interface DaemonAgentConnectionOptions {
closeClientOnDispose?: boolean;
Expand All @@ -87,6 +134,14 @@ 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 daemonLogPath: string | undefined;
private updateRestartPending = false;
private updateReconnectFailed = false;
private terminalCloseEmitted = false;
private updateReconnectPromise?: Promise<void>;
private disposed = false;
private readonly activeSideQuestionIds = new Set<string>();

constructor(
Expand All @@ -97,8 +152,24 @@ export class DaemonAgentConnection implements AgentConnection {
this.unsubscribeDaemonMessages = this.client.onMessage((message) => {
void this.handleDaemonMessage(message);
});
this.captureDaemonLogPath();
this.unsubscribeDaemonClose = this.client.onClose((error) => {
void this.emit({ type: "closed", error: error.message });
if (this.disposed || this.terminalCloseEmitted) {
return;
}
const closeReason = getDaemonSocketCloseReason(error);
if (closeReason === "shutdown") {
this.terminalCloseEmitted = true;
void this.emit({ type: "closed", error: this.formatDaemonSessionClosedError("shutdown") });
return;
}
if ((this.updateRestartPending || closeReason === "update") && !this.updateReconnectFailed) {
this.updateRestartPending = true;
void this.reconnectAfterUpdate();
Comment thread
cursor[bot] marked this conversation as resolved.
return;
}
Comment thread
cursor[bot] marked this conversation as resolved.
this.terminalCloseEmitted = true;
void this.emit({ type: "closed", error: this.formatDaemonConnectionClosedError(error) });
});
}

Expand Down Expand Up @@ -134,6 +205,13 @@ 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 ?? ("snapshot" in result ? result.snapshot.state.sessionFile : undefined);
this.captureDaemonLogPath();
this.updateReconnectFailed = false;
this.terminalCloseEmitted = false;
this.lastEventSequence = maxEventSequence(this.lastEventSequence, getAttachLastEventSequence(result));
if ("snapshot" in result) {
this.latestSnapshot = mapDaemonAttachSnapshot(result);
Expand Down Expand Up @@ -713,6 +791,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.
await Promise.allSettled([...this.activeSideQuestionIds].map((id) => this.abortSideQuestion(id)));
this.unsubscribeDaemonMessages();
this.unsubscribeDaemonClose();
Expand Down Expand Up @@ -768,6 +848,8 @@ export class DaemonAgentConnection implements AgentConnection {
return;
}
if (message.type === "session_replaced") {
this.attachedSessionId = message.state.sessionId;
this.attachedSessionFile = message.state.sessionFile;
const latestSnapshot: AgentConnectionSnapshot = {
state: message.state,
messages: message.messages,
Expand All @@ -792,8 +874,141 @@ export class DaemonAgentConnection implements AgentConnection {
return;
}
if (message.type === "session_closed") {
await this.emit({ type: "closed", error: message.reason });
if (message.reason === "update") {
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
this.captureDaemonLogPath();
this.updateRestartPending = true;
void this.reconnectAfterUpdate();
return;
}
this.terminalCloseEmitted = true;
await this.emit({ type: "closed", error: this.formatDaemonSessionClosedError(message.reason) });
}
}

private captureDaemonLogPath(): void {
const socketPath = this.client.hello?.socketPath;
if (socketPath) {
this.daemonLogPath = getDaemonLogPath(socketPath);
}
}

private formatDaemonSessionClosedError(reason: DaemonSessionClosedReason): string {
const explanation: Record<DaemonSessionClosedReason, string> = {
killed:
"The daemon stopped this agent session. Its transcript remains saved and can be reopened from Agents View.",
shutdown:
"The Prime Agent daemon shut down while this window was attached. The session transcript remains saved; restart Prime Agent and reopen it from Agents View.",
completed:
"The daemon closed this agent session after it completed. Its transcript remains available from Agents View.",
replaced:
"The daemon replaced this agent session with another session. Reopen the current session from Agents View.",
update:
"The Prime Agent daemon restarted for an update, but this window did not restore automatically. The session transcript remains saved; restart Prime Agent and reopen it from Agents View.",
};
return `${explanation[reason]} ${this.formatDaemonDiagnosticContext()}`;
}

private formatDaemonConnectionClosedError(error: Error): string {
return `Lost connection to the Prime Agent daemon. Cause: ${formatErrorSentence(error)} The session transcript remains saved; restart Prime Agent or reopen the session from Agents View. ${this.formatDaemonDiagnosticContext()}`;
}

private formatUpdateReconnectError(error: unknown): string {
return `The Prime Agent daemon restarted for an update, but this window could not reconnect to its restored session before the recovery timeout expired. Last error: ${formatErrorSentence(error)} The session transcript remains saved; restart Prime Agent and reopen it from Agents View. ${this.formatDaemonDiagnosticContext()}`;
}

private formatDaemonDiagnosticContext(): string {
const details: string[] = [];
if (this.attachedSessionId) {
details.push(`Session ID: ${this.attachedSessionId}.`);
}
if (this.attachedSessionFile) {
details.push(`Session file: ${this.attachedSessionFile}.`);
}
details.push(`Diagnostic log: ${this.daemonLogPath ?? getAgentLogPath()}.`);
return details.join(" ");
}

private reconnectAfterUpdate(): Promise<void> {
if (this.updateReconnectPromise) {
return this.updateReconnectPromise;
}
const reconnectPromise = reconnectDaemonTransportAfterUpdate(this.client)
.then(() => this.restoreConnectionAfterUpdate())
.catch(async (error: unknown) => {
this.updateRestartPending = false;
this.updateReconnectFailed = true;
if (!this.disposed) {
this.terminalCloseEmitted = true;
await this.emit({
type: "closed",
error: this.formatUpdateReconnectError(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);
if (this.disposed) {
return;
}
const response = await this.client.request({ type: "list" }, 30000);
if (this.disposed) {
return;
}
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) {
if (this.disposed) {
return;
}
this.activeSessionId = restored.activeSessionId;
this.lastEventSequence = undefined;
await this.attach();
if (this.disposed) {
return;
}
const snapshot = await this.getInitialSnapshot();
if (this.disposed) {
return;
}
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 {
Expand Down Expand Up @@ -830,6 +1045,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
Loading