Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
4c9dd6a
Protect live sessions during daemon updates
sethkarten Jul 7, 2026
c1cfff2
Add Claude Sonnet 5 to Prime Inference
Jul 7, 2026
1f4a387
fix(coding-agent): restore daemon sessions after update
sethkarten Jul 7, 2026
47c2962
fix(coding-agent): retry reopen busy update sessions
sethkarten Jul 7, 2026
7a48e04
fix(coding-agent): preserve live sessions across daemon updates
sethkarten Jul 8, 2026
f2c641e
fix(coding-agent): guard daemon relaunch races
sethkarten Jul 8, 2026
01a4a6a
fix(coding-agent): refuse relaunch on unknown live sessions
sethkarten Jul 8, 2026
20917a7
fix(coding-agent): avoid repeated update prompts
sethkarten Jul 8, 2026
55c8b52
fix(coding-agent): preserve forced daemon update consent
sethkarten Jul 8, 2026
3a727f7
fix(coding-agent): report automatic restore failures
sethkarten Jul 8, 2026
21f9472
fix(coding-agent): reuse takeover daemon probe
sethkarten Jul 8, 2026
84baecb
fix(coding-agent): recheck stale takeover sessions
sethkarten Jul 8, 2026
da8d772
fix(coding-agent): re-prompt for new stale takeover risk
sethkarten Jul 8, 2026
ffaf388
Merge origin/main into daemon update branch
sethkarten Jul 9, 2026
80fd344
Merge remote-tracking branch 'origin/main' into HEAD
sethkarten Jul 9, 2026
3f7e8ba
fix(coding-agent): protect cron ownership on restored sessions
sethkarten Jul 9, 2026
0d951d0
Merge remote-tracking branch 'origin/main' into HEAD
sethkarten Jul 9, 2026
f68ebd8
merge main into graceful daemon update, fixes #333
kevinjosethomas Jul 10, 2026
bbbded5
fix(coding-agent): preserve stale takeover restore sessions
sethkarten Jul 10, 2026
e7d2517
Merge origin/main into daemon update branch
sethkarten Jul 10, 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
2 changes: 2 additions & 0 deletions packages/ai/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## [Unreleased]

- Added Claude Sonnet 5 to the curated Prime Inference model catalog.

## [0.2.6] - 2026-07-06

## [0.2.5] - 2026-07-06
Expand Down
3 changes: 2 additions & 1 deletion packages/ai/scripts/generate-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ const PRIME_INFERENCE_MODEL_METADATA: Record<string, PrimeInferenceModelMetadata
"anthropic/claude-opus-4.8": { contextWindow: 200000, maxTokens: 128000, vision: true },
"anthropic/claude-sonnet-4.5": { contextWindow: 200000, maxTokens: 64000, vision: true },
"anthropic/claude-sonnet-4.6": { contextWindow: 200000, maxTokens: 128000, vision: true },
"anthropic/claude-sonnet-5": { contextWindow: 200000, maxTokens: 128000, vision: true },
"deepseek/deepseek-v3.2": { contextWindow: 128000, maxTokens: 8000 },
"deepseek/deepseek-v4-flash": { contextWindow: 1000000, maxTokens: 384000 },
"deepseek/deepseek-v4-pro": { contextWindow: 1000000, maxTokens: 384000 },
Expand Down Expand Up @@ -350,7 +351,7 @@ function isPrimeInferenceReasoningModel(modelId: string, catalogReasoning?: bool
id.startsWith("x-ai/grok-4") ||
id.startsWith("z-ai/glm-") ||
(id.startsWith("openai/gpt-5") && !id.includes("-chat")) ||
/^anthropic\/claude-(?:fable-5|opus-4|sonnet-4)/.test(id)
/^anthropic\/claude-(?:fable-5|opus-4|sonnet-4|sonnet-5)/.test(id)
);
}

Expand Down
19 changes: 19 additions & 0 deletions packages/ai/src/models.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13752,6 +13752,25 @@ export const MODELS = {
contextWindow: 200000,
maxTokens: 128000,
} satisfies Model<"openai-completions">,
"anthropic/claude-sonnet-5": {
id: "anthropic/claude-sonnet-5",
name: "Claude Sonnet 5",
api: "openai-completions",
provider: "prime-inference",
baseUrl: "https://api.pinference.ai/api/v1",
compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"maxTokensField":"max_tokens","supportsStrictMode":false},
reasoning: true,
thinkingLevelMap: {"xhigh":"xhigh","max":"max"},
input: ["text", "image"],
cost: {
input: 2,
output: 10,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 200000,
maxTokens: 128000,
} satisfies Model<"openai-completions">,
"deepseek/deepseek-v3.2": {
id: "deepseek/deepseek-v3.2",
name: "Deepseek V3.2",
Expand Down
30 changes: 30 additions & 0 deletions packages/ai/test/prime-inference-models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ describe("Prime Inference models", () => {
expect.arrayContaining([
"anthropic/claude-opus-4.7",
"anthropic/claude-opus-4.8",
"anthropic/claude-sonnet-5",
"deepseek/deepseek-v4-pro",
"minimax/minimax-m3",
"moonshotai/kimi-k2.7-code",
Expand Down Expand Up @@ -69,6 +70,35 @@ describe("Prime Inference models", () => {
});
});

it("registers Claude Sonnet 5 on Prime Inference", () => {
const model = getModel("prime-inference", "anthropic/claude-sonnet-5");

expect(model).toBeDefined();
expect(model.api).toBe("openai-completions");
expect(model.provider).toBe("prime-inference");
expect(model.baseUrl).toBe("https://api.pinference.ai/api/v1");
expect(model.reasoning).toBe(true);
expect(model.thinkingLevelMap).toEqual({ xhigh: "xhigh", max: "max" });
expect(getSupportedThinkingLevels(model)).toContain("xhigh");
expect(getSupportedThinkingLevels(model)).toContain("max");
expect(model.input).toEqual(["text", "image"]);
expect(model.contextWindow).toBe(200000);
expect(model.maxTokens).toBe(128000);
expect(model.cost).toEqual({
input: 2,
output: 10,
cacheRead: 0,
cacheWrite: 0,
});
expect(model.compat).toEqual({
supportsStore: false,
supportsDeveloperRole: false,
supportsReasoningEffort: true,
maxTokensField: "max_tokens",
supportsStrictMode: false,
});
});

it("marks known reasoning-capable Prime Inference model families", () => {
const opus48 = getModel("prime-inference", "anthropic/claude-opus-4.8");
expect(opus48.reasoning).toBe(true);
Expand Down
2 changes: 2 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## [Unreleased]

- Fixed daemon self-updates reopening previous restorable live sessions after relaunch.

## [0.2.6] - 2026-07-06

- Fixed the installer splash flickering during animation and resize by stabilizing full-screen redraws and removing misleading synthetic percentages ([ENG-4481](https://linear.app/primeintellect/issue/ENG-4481/installer-screen-is-unstable-and-flickery)).
Expand Down
186 changes: 152 additions & 34 deletions packages/coding-agent/src/cli/daemon-launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { dirname, resolve } from "node:path";
import { appendRotatingLog, expandTildePath, getClientErrorLogPath, VERSION } from "../config.js";
import { DaemonClient } from "../modes/daemon/daemon-client.js";
import { DAEMON_PROTOCOL_VERSION } from "../modes/daemon/daemon-protocol.js";
Expand Down Expand Up @@ -132,13 +132,89 @@ export async function shutdownDaemonAndWait(socketPath: string): Promise<boolean
}

// activeSessions is undefined when the daemon is reachable but its sessions couldn't
// be listed — callers must treat that as "possibly busy", not idle.
// be listed — callers must treat that as "possibly active", not idle.
export type RunningDaemonProbe = { reachable: false } | { reachable: true; activeSessions?: SessionSummary[] };

export function isSessionBusy(summary: SessionSummary): boolean {
// pendingMessageCount covers queued steering/follow-ups, which live only in
// memory and would be lost if the daemon were stopped.
return summary.isStreaming || summary.isCompacting || summary.pendingMessageCount > 0;
export interface DaemonSessionRestoreFailure {
sessionFile: string;
error: string;
}

export interface DaemonSessionRestoreResult {
restored: number;
total: number;
failed: DaemonSessionRestoreFailure[];
}

export function hasSessionVolatileWorkForDaemonStop(summary: SessionSummary): boolean {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
// These states live in daemon memory and cannot be reconstructed by reopening
// the JSONL session file after the daemon restarts.
return (
summary.isStreaming || summary.isCompacting || summary.isBashRunning === true || summary.pendingMessageCount > 0
);
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
}

export function isSessionReopenableAfterDaemonStop(summary: SessionSummary): boolean {
return (
summary.activeSessionId !== undefined && summary.runtimeKind !== "subagent" && summary.sessionFile !== undefined
);
}

export function isSessionRestorableAfterDaemonStop(summary: SessionSummary): boolean {
return isSessionReopenableAfterDaemonStop(summary) && !hasSessionVolatileWorkForDaemonStop(summary);
}

export function isSessionAtRiskFromDaemonStop(summary: SessionSummary): boolean {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
if (summary.activeSessionId === undefined) {
return hasSessionVolatileWorkForDaemonStop(summary);
}
return !isSessionRestorableAfterDaemonStop(summary);
}

function restoreCandidateSessionFiles(sessions: readonly SessionSummary[]): string[] {
return [
...new Set(
sessions
.filter(isSessionReopenableAfterDaemonStop)
.map((session) => session.sessionFile)
.filter((sessionFile): sessionFile is string => sessionFile !== undefined)
.map((sessionFile) => resolve(sessionFile)),
),
];
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
}

export async function restoreDaemonSessionSummaries(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium cli/daemon-launch.ts:215

restoreDaemonSessionSummaries recreates each session with a config object containing only sessionDir and cwd, dropping the per-session runtime settings (model/provider, thinkingLevel, tool/extension restrictions, etc.) from the original live session. createRuntime() fills missing fields from the daemon defaults, so after a stale-daemon takeover an idle live session can reopen with a different model and tool restrictions than it was running with before the restart. Consider forwarding the original session's runtime config fields into the create request so the reopened session preserves its settings.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/coding-agent/src/cli/daemon-launch.ts around line 215:

`restoreDaemonSessionSummaries` recreates each session with a `config` object containing only `sessionDir` and `cwd`, dropping the per-session runtime settings (model/provider, `thinkingLevel`, tool/extension restrictions, etc.) from the original live session. `createRuntime()` fills missing fields from the daemon defaults, so after a stale-daemon takeover an idle live session can reopen with a different model and tool restrictions than it was running with before the restart. Consider forwarding the original session's runtime config fields into the `create` request so the reopened session preserves its settings.

socketPath: string,
sessions: readonly SessionSummary[],
): Promise<DaemonSessionRestoreResult> {
const sessionFiles = restoreCandidateSessionFiles(sessions);
if (sessionFiles.length === 0) {
return { restored: 0, total: 0, failed: [] };
}

const client = new DaemonClient(socketPath);
await client.connect(3000);
let restored = 0;
const failed: DaemonSessionRestoreFailure[] = [];
try {
for (const sessionFile of sessionFiles) {
const response = await client.request({
type: "create",
sessionPath: sessionFile,
config: {
sessionDir: dirname(sessionFile),
},
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
});
if (response.success) {
restored++;
} else {
failed.push({ sessionFile, error: response.error });
}
}
} finally {
client.close();
}
return { restored, total: sessionFiles.length, failed };
}

export async function probeRunningDaemonSessions(socketPath: string): Promise<RunningDaemonProbe> {
Expand All @@ -159,23 +235,25 @@ export async function probeRunningDaemonSessions(socketPath: string): Promise<Ru
}
}

// Idle-but-loaded sessions reload from disk on the fresh daemon, so only a busy
// session blocks replacing a stale daemon.
async function shutdownStaleDaemonIfNotBusy(socketPath: string): Promise<boolean> {
interface StaleDaemonShutdownResult {
stopped: boolean;
restoreSessions: SessionSummary[];
}

// Sessions with volatile in-memory work still block implicit replacement. Idle
// persisted top-level sessions can be reopened after the fresh daemon starts.
async function shutdownStaleDaemonForReplacement(socketPath: string): Promise<StaleDaemonShutdownResult> {
const client = new DaemonClient(socketPath);
let connected = false;
let hasBusySessions = false;
let loadedSessionCount = 0;
let summaries: SessionSummary[] | undefined;
try {
await client.connect(1000);
connected = true;
try {
const summaries = await listActiveDaemonSessionSummaries(client);
loadedSessionCount = summaries.length;
hasBusySessions = summaries.some(isSessionBusy);
summaries = await listActiveDaemonSessionSummaries(client);
} catch {
// Couldn't confirm idleness: treat as busy rather than risk interrupting work.
hasBusySessions = true;
// Couldn't confirm the session state: treat as active rather than risk interrupting work.
summaries = undefined;
}
} catch {
// Couldn't reach it to inspect; don't send a blind shutdown, just verify below.
Expand All @@ -184,30 +262,25 @@ async function shutdownStaleDaemonIfNotBusy(socketPath: string): Promise<boolean
}

if (!connected) {
return waitForDaemonGone(socketPath);
return { stopped: await waitForDaemonGone(socketPath), restoreSessions: [] };
}
if (hasBusySessions) {
logDaemonLaunch(`refusing to replace stale daemon on ${socketPath}: busy session(s) present`);
return false;
if (!summaries) {
logDaemonLaunch(`refusing to replace stale daemon on ${socketPath}: session list unavailable`);
return { stopped: false, restoreSessions: [] };
}
const atRiskSessions = summaries.filter(isSessionAtRiskFromDaemonStop);
if (atRiskSessions.length > 0) {
logDaemonLaunch(`refusing to replace stale daemon on ${socketPath}: unrestorable live session(s) present`);
return { stopped: false, restoreSessions: [] };
}
const restoreSessions = summaries.filter(isSessionRestorableAfterDaemonStop);
logDaemonLaunch(
`replacing stale daemon on ${socketPath} (idle): ${loadedSessionCount} loaded session(s) will reload`,
`replacing stale daemon on ${socketPath}: ${restoreSessions.length} live session(s) will be reopened`,
);
return shutdownDaemonAndWait(socketPath);
return { stopped: await shutdownDaemonAndWait(socketPath), restoreSessions };
}

async function ensureDaemonRunning(socketPath: string, spawnCwd?: string): Promise<void> {
const probe = await probeDaemonVersion(socketPath);
if (probe === "current") {
return;
}
if (probe === "stale") {
const stopped = await shutdownStaleDaemonIfNotBusy(socketPath);
if (!stopped) {
throw new StaleDaemonError(socketPath);
}
}

async function spawnDaemonAndWait(socketPath: string, spawnCwd?: string): Promise<void> {
const entrypoint = process.argv[1];
if (!entrypoint) {
throw new Error("Cannot determine current CLI entrypoint for daemon launch");
Expand Down Expand Up @@ -238,6 +311,51 @@ async function ensureDaemonRunning(socketPath: string, spawnCwd?: string): Promi
throw new Error(`Timed out waiting for daemon to start on ${socketPath}`);
}

export async function relaunchDaemonAndRestoreSessions(
socketPath: string,
sessions: readonly SessionSummary[],
spawnCwd?: string,
): Promise<DaemonSessionRestoreResult> {
const stopped = await shutdownDaemonAndWait(socketPath);
if (!stopped) {
throw new Error(`Could not stop daemon on ${socketPath}`);
}
await spawnDaemonAndWait(socketPath, spawnCwd);
return restoreDaemonSessionSummaries(socketPath, sessions);
}

export async function spawnDaemonAndRestoreSessions(
socketPath: string,
sessions: readonly SessionSummary[],
spawnCwd?: string,
): Promise<DaemonSessionRestoreResult> {
await spawnDaemonAndWait(socketPath, spawnCwd);
return restoreDaemonSessionSummaries(socketPath, sessions);
}

async function ensureDaemonRunning(socketPath: string, spawnCwd?: string): Promise<void> {
const probe = await probeDaemonVersion(socketPath);
if (probe === "current") {
return;
}
let restoreSessions: SessionSummary[] = [];
if (probe === "stale") {
const result = await shutdownStaleDaemonForReplacement(socketPath);
if (!result.stopped) {
throw new StaleDaemonError(socketPath);
}
restoreSessions = result.restoreSessions;
}

const restoreResult = await spawnDaemonAndRestoreSessions(socketPath, restoreSessions, spawnCwd);
if (restoreResult.failed.length > 0) {
logDaemonLaunch(
`restored ${restoreResult.restored}/${restoreSessions.length} session(s) after daemon replacement; ` +
`${restoreResult.failed.length} failed`,
);
}
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
}

const ensurePromises = new Map<string, Promise<void>>();

/**
Expand Down
Loading