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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ version with its date and start a fresh empty `[Unreleased]` above it.

## [Unreleased]

### Fixed

- Upgraded the embedded Qoder Agent SDK so it stays compatible with qodercli
1.1.81.

## [1.0.17] - 2026-10-10

### Changed
Expand Down
39 changes: 4 additions & 35 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
"@codemirror/state": "^6.5.0",
"@codemirror/view": "^6.38.6",
"@modelcontextprotocol/sdk": "~1.32.1",
"@qoder-ai/qoder-agent-sdk": "^1.0.45",
"@qoder-ai/qoder-agent-sdk": "^1.0.58",
"tslib": "^2.8.1"
},
"overrides": {
Expand Down
22 changes: 21 additions & 1 deletion scripts/renderer-safe-unref.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ const UNSAFE_TIMER_UNREF_PATTERNS = [
'\n closeTimeout.unref?.();' +
'\n })',
},
{
name: 'qoder-sdk-daemon-heartbeat-watchdog',
pattern: /this\.heartbeatWatchdog\.unref\(\)/g,
replacement: 'this.heartbeatWatchdog.unref?.()',
},
];

const TIMER_CALL_PREFIXES = ['setTimeout(', 'setInterval('];
Expand Down Expand Up @@ -143,7 +148,8 @@ function findUnsafeTimerUnrefSites(contents) {
continue;
}

const unrefMatch = contents.slice(callEnd + 1).match(/^\s*\.unref\(\)/);
const unrefMatch = contents.slice(callEnd + 1).match(/^\s*\.unref\(\)/)
?? matchAssignedTimerUnref(contents, timerStart.startIndex, callEnd);
if (unrefMatch) {
const startIndex = timerStart.startIndex;
const endIndex = callEnd + 1 + unrefMatch[0].length;
Expand All @@ -162,6 +168,20 @@ function findUnsafeTimerUnrefSites(contents) {
return matches;
}

function matchAssignedTimerUnref(contents, timerStartIndex, callEnd) {
const assignment = contents
.slice(Math.max(0, timerStartIndex - 128), timerStartIndex)
.match(/([A-Za-z_$][A-Za-z0-9_$.]*)\s*=\s*$/);
if (!assignment) {
return null;
}

const target = assignment[1].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return contents
.slice(callEnd + 1)
.match(new RegExp(`^\\s*,\\s*${target}\\.unref\\(\\)`));
}

function findNextTimerCall(contents, startIndex) {
let nextMatch = null;

Expand Down
7 changes: 7 additions & 0 deletions scripts/smoke-qoder-sdk.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
#!/usr/bin/env node

import {
ProcessTransport,
qodercliAuth,
query,
shutdownDaemon,
} from '@qoder-ai/qoder-agent-sdk';

/** Idle async input used for initialization-only SDK operations. */
Expand Down Expand Up @@ -38,6 +40,9 @@ async function main() {
cwd: process.cwd(),
model: 'auto',
persistSession: false,
// The plugin pins the process carrier so the user's installed CLI runs;
// without this the SDK starts its bundled worker runtime instead.
transport: ProcessTransport.default,
...(cliPath ? { pathToQoderCLIExecutable: cliPath } : {}),
},
});
Expand All @@ -59,6 +64,8 @@ async function main() {
} finally {
input.end();
await catalogQuery.close();
// The daemon is process-wide and keeps Node alive until it is stopped.
await shutdownDaemon();
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/features/settings/ui/qoder-settings-tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ export function renderQoderCliPathControl(
await view?.getTabManager()?.broadcastToAllTabs(
(service) => Promise.resolve(service.cleanup())
);
await qoderWorkspace.shutdownRuntime();
void qoderWorkspace.agentCatalog.refresh();
return true;
};
Expand Down Expand Up @@ -196,6 +197,7 @@ export function renderQoderCliEditionControl(
qoderWorkspace.cliResolver.reset();
await qoderWorkspace.pluginManager.loadPlugins();
await context.plugin.reloadConversationIndex();
await qoderWorkspace.shutdownRuntime();
void qoderWorkspace.agentCatalog.refresh();
});
});
Expand Down
4 changes: 2 additions & 2 deletions src/qoder/commands/probe-runtime-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
import { sortThinkingEfforts } from '../models/model-catalog';
import type { QoderHostContext } from '../qoder-host-context';
import { createCustomSpawnFunction } from '../runtime/custom-spawn';
import { buildDaemonLaunchOptions } from '../runtime/qoder-daemon-launch';

const STDERR_TAIL_LIMIT = 4_000;

Expand Down Expand Up @@ -190,8 +191,7 @@ export function buildProbeOptions(
cwd: vaultPath,
abortController,
auth: qodercliAuth(),
pathToQoderCLIExecutable: cliPath,
env: { ...process.env, PATH: enhancedPath },
...buildDaemonLaunchOptions(cliPath, enhancedPath),
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
settingSources: resolveQoderSettingSources(qoderSettings.loadUserSettings),
Expand Down
22 changes: 18 additions & 4 deletions src/qoder/qoder-services.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { shutdownDaemon } from '@qoder-ai/qoder-agent-sdk';

import { getVaultPath } from '../core/fs/path';
import type { ChatRuntime } from '../core/runtime/chat-runtime';
import type { VaultFileAdapter } from '../core/storage/vault-file-adapter';
Expand Down Expand Up @@ -47,6 +49,12 @@ export interface QoderServices {
historyService: QoderConversationHistoryService;
taskResultInterpreter: QoderTaskResultInterpreter;
loginService: QoderLoginService;
/**
* Stops the SDK's process-wide daemon. Required after CLI path/edition
* changes and login, because a running daemon keeps its original launch
* configuration and credentials.
*/
shutdownRuntime(): Promise<void>;
dispose(): void;
createRuntime(): ChatRuntime;
createTitleGenerationService(): QoderTitleGenerationService;
Expand Down Expand Up @@ -111,12 +119,16 @@ export async function createQoderServices(

const historyService = new QoderConversationHistoryService();
const taskResultInterpreter = new QoderTaskResultInterpreter();
const shutdownRuntime = async (): Promise<void> => {
await shutdownDaemon().catch(() => {
// Best-effort: a failed shutdown must not block settings/login flows.
});
};
const loginService = new QoderLoginService(plugin, () => {
if (disposed) return;
// Always return to idle once the post-login refresh settles; keeping
// 'succeeded' would render a stale "checking status" panel if the runtime
// later becomes authRequired again in this session.
void agentCatalog.refresh().then(() => {
// Credentials are written by the CLI after the daemon may already have
// started, so restart the daemon before re-probing the runtime.
void shutdownRuntime().then(() => agentCatalog.refresh()).then(() => {
if (!disposed) loginService.reset();
});
});
Expand All @@ -134,8 +146,10 @@ export async function createQoderServices(
historyService,
taskResultInterpreter,
loginService,
shutdownRuntime,
dispose: () => {
disposed = true;
void shutdownRuntime();
loginService.dispose();
if (startupRetryTimer !== null) {
window.clearTimeout(startupRetryTimer);
Expand Down
20 changes: 4 additions & 16 deletions src/qoder/runtime/custom-spawn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,16 @@ import type { SpawnedProcess, SpawnOptions } from '@qoder-ai/qoder-agent-sdk';
import { type ChildProcess, spawn } from 'child_process';

import { cliPathRequiresNode, findNodeExecutable } from '../../core/env/environment';
import {
CUSTOM_PROVIDER_CLI_ENV,
INTEGRATION_CLI_ENV,
} from './qoder-daemon-launch';
import {
resolveWindowsCmdShimSpawnSpec,
terminateSpawnedProcess,
type WindowsCmdShimSpawnSpec,
} from './windows-cmd-shim';

/**
* SDK-spawned sessions run qodercli in its "sdk" runtime mode, where
* CLI-configured custom providers (self-defined base URLs) stay disabled
* unless this opt-in is present — without it the CLI hides their models.
*/
const CUSTOM_PROVIDER_CLI_ENV = { QODER_SDK_CUSTOM_BASE_URL_BYOK: '1' } as const;

/**
* Identifies this host integration to the CLI, which reports it as the
* `integration_id` telemetry attribute. Only a UUIDv4 is accepted; any other
* value is silently dropped.
*/
const INTEGRATION_CLI_ENV = {
QODER_INTEGRATION_ID: '70457212-a449-4ae3-af1e-5fb9e00fafcf',
} as const;

/**
* Spawn qodercli in Obsidian's Electron process.
*
Expand Down
16 changes: 8 additions & 8 deletions src/qoder/runtime/qoder-chat-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,6 @@ export class QoderChatRuntime implements ChatRuntime {
this.sessionManager.clearHistoryRebuild();
this.pendingForkSession = false;
}
this.messageChannel?.setSessionId(event.sessionId);
if (event.agents) {
try { this.getAgentCatalog().applySessionAgents(event.agents); } catch { /* non-critical */ }
}
Expand Down Expand Up @@ -441,7 +440,6 @@ export class QoderChatRuntime implements ChatRuntime {
this.messageChannel = new QoderMessageChannel();

if (resumeSessionId) {
this.messageChannel.setSessionId(resumeSessionId);
this.sessionManager.setSessionId(resumeSessionId, this.getScopedSettings().model);
}

Expand Down Expand Up @@ -874,10 +872,14 @@ export class QoderChatRuntime implements ChatRuntime {
if (shouldUsePersistent) {
// Start persistent query if not running
if (!this.persistentQuery && !this.shuttingDown) {
// Seed the launch config with this turn's external context so the
// first applyDynamicUpdates() does not restart a query whose daemon
// session is still opening.
await this.startPersistentQuery(
vaultPath,
resolvedQoderPath,
this.sessionManager.getSessionId() ?? undefined
this.sessionManager.getSessionId() ?? undefined,
effectiveQueryOptions?.externalContextPaths ?? this.currentExternalContextPaths
);
}

Expand Down Expand Up @@ -1034,11 +1036,9 @@ export class QoderChatRuntime implements ChatRuntime {
}

private buildSDKUserMessage(prompt: string, images?: ImageAttachment[]): SDKUserMessage {
return buildQoderSDKUserMessage(
prompt,
this.sessionManager.getSessionId() || '',
images,
);
// The daemon owns the session id and stamps it onto each message; sending
// a stale one is rejected with a session_id mismatch.
return buildQoderSDKUserMessage(prompt, images);
}

/**
Expand Down
7 changes: 2 additions & 5 deletions src/qoder/runtime/qoder-cold-start-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import type { QoderHostContext } from '../qoder-host-context';
import { extractAssistantText } from '../services/extract-assistant-text';
import { createCustomSpawnFunction } from './custom-spawn';
import { buildDaemonLaunchOptions } from './qoder-daemon-launch';

export interface ColdStartQueryConfig {
plugin: QoderHostContext;
Expand Down Expand Up @@ -77,11 +78,7 @@ export async function runColdStartQuery(
model: selectedModel,
abortController: config.abortController,
auth: qodercliAuth(),
pathToQoderCLIExecutable: resolvedQoderPath,
env: {
...process.env,
PATH: enhancedPath,
},
...buildDaemonLaunchOptions(resolvedQoderPath, enhancedPath),
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
settingSources: resolveQoderSettingSources(qoderSettings.loadUserSettings),
Expand Down
51 changes: 51 additions & 0 deletions src/qoder/runtime/qoder-daemon-launch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { Options } from '@qoder-ai/qoder-agent-sdk';
import { ProcessTransport } from '@qoder-ai/qoder-agent-sdk';

/**
* SDK-spawned sessions run qodercli in its "sdk" runtime mode, where
* CLI-configured custom providers (self-defined base URLs) stay disabled
* unless this opt-in is present — without it the CLI hides their models.
*/
export const CUSTOM_PROVIDER_CLI_ENV = { QODER_SDK_CUSTOM_BASE_URL_BYOK: '1' } as const;

/**
* Identifies this host integration to the CLI, which reports it as the
* `integration_id` telemetry attribute. Only a UUIDv4 is accepted; any other
* value is silently dropped.
*/
export const INTEGRATION_CLI_ENV = {
QODER_INTEGRATION_ID: '70457212-a449-4ae3-af1e-5fb9e00fafcf',
} as const;

export interface DaemonLaunchOptions {
pathToQoderCLIExecutable: string;
transport: Options['transport'];
env: Record<string, string | undefined>;
}

/**
* Builds the daemon-bound options shared by every Query in this host.
*
* The SDK owns the daemon process and ignores `spawnQoderCLIProcess` when it
* launches that process, so launch-critical settings (the CLI entry and its
* environment) must live in these options instead. The daemon is process-wide
* and rejects a Query whose launch options differ, so every query site must
* build them through this helper.
*/
export function buildDaemonLaunchOptions(
cliPath: string,
enhancedPath: string,
extraEnv?: Record<string, string>,
): DaemonLaunchOptions {
return {
pathToQoderCLIExecutable: cliPath,
transport: ProcessTransport.default,
env: {
...process.env,
...extraEnv,
...CUSTOM_PROVIDER_CLI_ENV,
...INTEGRATION_CLI_ENV,
PATH: enhancedPath,
},
};
}
Loading
Loading