From 370cc5a9e5d05438865483973fabcc11d19f2026 Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Fri, 30 Oct 2026 13:58:30 +0800 Subject: [PATCH 1/5] fix(qoder): upgrade agent SDK to 1.0.58 for qodercli 1.1.81 qodercli 1.1.81 rejects SDK worker sessions with sdk_worker_removed, so SDK 1.0.45 can no longer start a session. SDK 1.0.58 pairs with 1.1.81, but it moves every query onto a process-wide daemon: the daemon ignores spawnQoderCLIProcess and defaults to its bundled worker runtime, which the plugin does not ship. Pin ProcessTransport.default so the daemon launches the user's CLI, pass the SDK runtime env opt-ins through options.env (the spawn hook no longer sees the daemon launch), and stop the daemon when the CLI path or edition changes, after login, and on plugin unload so new configuration and credentials take effect. Co-authored-by: QoderAI (Qwen 3.8 Max) --- package.json | 2 +- scripts/smoke-qoder-sdk.mjs | 7 +++ .../settings/ui/qoder-settings-tab.ts | 2 + src/qoder/commands/probe-runtime-commands.ts | 4 +- src/qoder/qoder-services.ts | 22 ++++++-- src/qoder/runtime/custom-spawn.ts | 20 ++------ src/qoder/runtime/qoder-cold-start-query.ts | 7 +-- src/qoder/runtime/qoder-daemon-launch.ts | 51 +++++++++++++++++++ .../runtime/qoder-query-options-builder.ts | 8 +-- tests/__mocks__/qoder-agent-sdk.ts | 8 +++ .../settings/ui/qoder-settings-tab.test.ts | 1 + .../qoder/runtime/qoder-daemon-launch.test.ts | 36 +++++++++++++ .../qoder-query-options-builder.test.ts | 22 ++++++++ 13 files changed, 156 insertions(+), 34 deletions(-) create mode 100644 src/qoder/runtime/qoder-daemon-launch.ts create mode 100644 tests/unit/qoder/runtime/qoder-daemon-launch.test.ts diff --git a/package.json b/package.json index f93655f..ad7545b 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/scripts/smoke-qoder-sdk.mjs b/scripts/smoke-qoder-sdk.mjs index 24909cf..025fa52 100644 --- a/scripts/smoke-qoder-sdk.mjs +++ b/scripts/smoke-qoder-sdk.mjs @@ -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. */ @@ -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 } : {}), }, }); @@ -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(); } } diff --git a/src/features/settings/ui/qoder-settings-tab.ts b/src/features/settings/ui/qoder-settings-tab.ts index 3526650..7545d9e 100644 --- a/src/features/settings/ui/qoder-settings-tab.ts +++ b/src/features/settings/ui/qoder-settings-tab.ts @@ -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; }; @@ -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(); }); }); diff --git a/src/qoder/commands/probe-runtime-commands.ts b/src/qoder/commands/probe-runtime-commands.ts index e34597c..4f5f84d 100644 --- a/src/qoder/commands/probe-runtime-commands.ts +++ b/src/qoder/commands/probe-runtime-commands.ts @@ -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; @@ -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), diff --git a/src/qoder/qoder-services.ts b/src/qoder/qoder-services.ts index 1bd6c1a..fb960fd 100644 --- a/src/qoder/qoder-services.ts +++ b/src/qoder/qoder-services.ts @@ -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'; @@ -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; dispose(): void; createRuntime(): ChatRuntime; createTitleGenerationService(): QoderTitleGenerationService; @@ -111,12 +119,16 @@ export async function createQoderServices( const historyService = new QoderConversationHistoryService(); const taskResultInterpreter = new QoderTaskResultInterpreter(); + const shutdownRuntime = async (): Promise => { + 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(); }); }); @@ -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); diff --git a/src/qoder/runtime/custom-spawn.ts b/src/qoder/runtime/custom-spawn.ts index 74f053a..3e9e8ce 100644 --- a/src/qoder/runtime/custom-spawn.ts +++ b/src/qoder/runtime/custom-spawn.ts @@ -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. * diff --git a/src/qoder/runtime/qoder-cold-start-query.ts b/src/qoder/runtime/qoder-cold-start-query.ts index e471481..d25884f 100644 --- a/src/qoder/runtime/qoder-cold-start-query.ts +++ b/src/qoder/runtime/qoder-cold-start-query.ts @@ -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; @@ -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), diff --git a/src/qoder/runtime/qoder-daemon-launch.ts b/src/qoder/runtime/qoder-daemon-launch.ts new file mode 100644 index 0000000..e8c5cb1 --- /dev/null +++ b/src/qoder/runtime/qoder-daemon-launch.ts @@ -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; +} + +/** + * 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, +): DaemonLaunchOptions { + return { + pathToQoderCLIExecutable: cliPath, + transport: ProcessTransport.default, + env: { + ...process.env, + ...extraEnv, + ...CUSTOM_PROVIDER_CLI_ENV, + ...INTEGRATION_CLI_ENV, + PATH: enhancedPath, + }, + }; +} diff --git a/src/qoder/runtime/qoder-query-options-builder.ts b/src/qoder/runtime/qoder-query-options-builder.ts index c4cdf1f..c32819e 100644 --- a/src/qoder/runtime/qoder-query-options-builder.ts +++ b/src/qoder/runtime/qoder-query-options-builder.ts @@ -21,6 +21,7 @@ import { type SystemPromptSettings, } from '../prompt/main-agent'; import { createCustomSpawnFunction } from './custom-spawn'; +import { buildDaemonLaunchOptions } from './qoder-daemon-launch'; import { DISABLED_BUILTIN_SUBAGENTS, type PersistentQueryConfig, @@ -270,13 +271,8 @@ export class QueryOptionsBuilder { model, abortController, auth: qodercliAuth(), - pathToQoderCLIExecutable: ctx.cliPath, + ...buildDaemonLaunchOptions(ctx.cliPath, ctx.enhancedPath, ctx.customEnv), settingSources: resolveQoderSettingSources(qoderSettings.loadUserSettings), - env: { - ...process.env, - ...ctx.customEnv, - PATH: ctx.enhancedPath, - }, includePartialMessages: true, }; diff --git a/tests/__mocks__/qoder-agent-sdk.ts b/tests/__mocks__/qoder-agent-sdk.ts index 23dc8a0..e79ed58 100644 --- a/tests/__mocks__/qoder-agent-sdk.ts +++ b/tests/__mocks__/qoder-agent-sdk.ts @@ -60,6 +60,14 @@ export function qodercliAuth() { return { type: 'qodercli' } as const; } +export const ProcessTransport = { + default: { kind: 'process-transport' as const }, +}; + +export async function shutdownDaemon(): Promise { + // No daemon exists in unit tests. +} + export interface Settings { effortLevel?: 'low' | 'medium' | 'high' | 'xhigh' | 'max'; } diff --git a/tests/unit/features/settings/ui/qoder-settings-tab.test.ts b/tests/unit/features/settings/ui/qoder-settings-tab.test.ts index d226574..e4ab257 100644 --- a/tests/unit/features/settings/ui/qoder-settings-tab.test.ts +++ b/tests/unit/features/settings/ui/qoder-settings-tab.test.ts @@ -323,6 +323,7 @@ function createPlugin(overrides: Record = {}): any { pluginManager: { loadPlugins: jest.fn().mockResolvedValue(undefined), }, + shutdownRuntime: jest.fn().mockResolvedValue(undefined), }, saveSettings: mockSaveSettings, normalizeModelVariantSettings: jest.fn(() => false), diff --git a/tests/unit/qoder/runtime/qoder-daemon-launch.test.ts b/tests/unit/qoder/runtime/qoder-daemon-launch.test.ts new file mode 100644 index 0000000..28e048f --- /dev/null +++ b/tests/unit/qoder/runtime/qoder-daemon-launch.test.ts @@ -0,0 +1,36 @@ +import { ProcessTransport } from '@qoder-ai/qoder-agent-sdk'; + +import { + buildDaemonLaunchOptions, + CUSTOM_PROVIDER_CLI_ENV, + INTEGRATION_CLI_ENV, +} from '@/qoder/runtime/qoder-daemon-launch'; + +describe('buildDaemonLaunchOptions', () => { + it('pins the process carrier so the installed CLI is used', () => { + const options = buildDaemonLaunchOptions('/opt/qodercli', '/opt/bin'); + + expect(options.transport).toBe(ProcessTransport.default); + expect(options.pathToQoderCLIExecutable).toBe('/opt/qodercli'); + }); + + it('injects the SDK runtime opt-ins into the daemon environment', () => { + const options = buildDaemonLaunchOptions('/opt/qodercli', '/opt/bin'); + + expect(options.env).toMatchObject({ + ...CUSTOM_PROVIDER_CLI_ENV, + ...INTEGRATION_CLI_ENV, + PATH: '/opt/bin', + }); + }); + + it('merges caller env without letting it override PATH', () => { + const options = buildDaemonLaunchOptions('/opt/qodercli', '/opt/bin', { + CUSTOM_FLAG: '1', + PATH: '/ignored', + }); + + expect(options.env.CUSTOM_FLAG).toBe('1'); + expect(options.env.PATH).toBe('/opt/bin'); + }); +}); diff --git a/tests/unit/qoder/runtime/qoder-query-options-builder.test.ts b/tests/unit/qoder/runtime/qoder-query-options-builder.test.ts index 0b2a36b..0d546ab 100644 --- a/tests/unit/qoder/runtime/qoder-query-options-builder.test.ts +++ b/tests/unit/qoder/runtime/qoder-query-options-builder.test.ts @@ -1,4 +1,5 @@ import type { ModelPolicyContext } from '@qoder-ai/qoder-agent-sdk'; +import { ProcessTransport } from '@qoder-ai/qoder-agent-sdk'; import type { AppPluginManager } from '@/core/types/services'; import type { PermissionMode, QoderianSettings } from '@/core/types/settings'; @@ -199,3 +200,24 @@ describe('QueryOptionsBuilder memory', () => { expect(QueryOptionsBuilder.needsRestart(off, off)).toBe(false); }); }); + +describe('QueryOptionsBuilder daemon launch options', () => { + it('pins the process carrier and SDK env for every query shape', () => { + const persistent = QueryOptionsBuilder.buildPersistentQueryOptions( + createContext('default'), + ); + const coldStart = QueryOptionsBuilder.buildColdStartQueryOptions( + createColdStartContext(), + ); + + for (const options of [persistent, coldStart]) { + expect(options.transport).toBe(ProcessTransport.default); + expect(options.pathToQoderCLIExecutable).toBe('/test/qodercli'); + expect(options.env).toMatchObject({ + QODER_SDK_CUSTOM_BASE_URL_BYOK: '1', + QODER_INTEGRATION_ID: '70457212-a449-4ae3-af1e-5fb9e00fafcf', + PATH: '/test/bin', + }); + } + }); +}); From f91209ca1065a7fa41a26467317e5757f2065ab3 Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Fri, 30 Oct 2026 14:22:52 +0800 Subject: [PATCH 2/5] fix(qoder): guard the SDK daemon heartbeat timer for the renderer Obsidian plugins run in a renderer process where setInterval returns a numeric id, so the SDK's heartbeat watchdog threw "this.heartbeatWatchdog.unref is not a function" on the first query. Extend the existing renderer unref build patch to rewrite that site to unref?.() and teach the unsafe-site guard to spot the assignment-plus-unref shape. Co-authored-by: QoderAI (Qwen 3.8 Max) --- scripts/renderer-safe-unref.js | 22 +++++++++++++++++- .../unit/scripts/renderer-safe-unref.test.ts | 23 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/scripts/renderer-safe-unref.js b/scripts/renderer-safe-unref.js index 77c5588..422feac 100644 --- a/scripts/renderer-safe-unref.js +++ b/scripts/renderer-safe-unref.js @@ -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(']; @@ -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; @@ -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; diff --git a/tests/unit/scripts/renderer-safe-unref.test.ts b/tests/unit/scripts/renderer-safe-unref.test.ts index f3453ed..b893a22 100644 --- a/tests/unit/scripts/renderer-safe-unref.test.ts +++ b/tests/unit/scripts/renderer-safe-unref.test.ts @@ -87,6 +87,29 @@ describe('rendererSafeUnref helpers', () => { expect(result.contents.split('\n')).toHaveLength(input.split('\n').length); }); + it('patches the qoder-sdk daemon heartbeat watchdog shape', () => { + const input = 'this.heartbeatWatchdog=setInterval(()=>{' + + 'if(this.failure||this.stopping||!this.initialized)return;' + + '},e),this.heartbeatWatchdog.unref()'; + + expect(findUnsafeTimerUnrefSites(input)).toEqual([ + { + line: 1, + snippet: 'setInterval(()=>{' + + 'if(this.failure||this.stopping||!this.initialized)return;' + + '},e),this.heartbeatWatchdog.unref()', + }, + ]); + + const result = patchRendererUnsafeUnrefSites(input); + + expect(result.appliedPatches).toEqual([ + { name: 'qoder-sdk-daemon-heartbeat-watchdog', count: 1 }, + ]); + expect(result.contents).toContain('this.heartbeatWatchdog.unref?.()'); + expect(findUnsafeTimerUnrefSites(result.contents)).toEqual([]); + }); + it('reports remaining direct timer .unref() calls but ignores guarded usage', () => { const input = [ 'const timer = setTimeout(run, 1000);', From 086ad9579619a7e5bda6b810f947176eb880258e Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Fri, 30 Oct 2026 14:27:12 +0800 Subject: [PATCH 3/5] fix(qoder): let the daemon stamp user message session ids SDK 1.0.58 validates a user message's session_id against the daemon session and rejects a stale value, which broke every send with "Query message session_id must match daemon Session". Drop the field from user messages so the SDK stamps the live session id itself. Co-authored-by: QoderAI (Qwen 3.8 Max) --- src/qoder/runtime/qoder-chat-runtime.ts | 10 +++------- src/qoder/runtime/qoder-message-channel.ts | 6 ------ src/qoder/runtime/qoder-user-message-factory.ts | 3 --- tests/unit/qoder/runtime/qoder-chat-runtime.test.ts | 5 ++--- tests/unit/qoder/runtime/qoder-message-channel.test.ts | 5 ++--- 5 files changed, 7 insertions(+), 22 deletions(-) diff --git a/src/qoder/runtime/qoder-chat-runtime.ts b/src/qoder/runtime/qoder-chat-runtime.ts index 7e32ed5..a02cbab 100644 --- a/src/qoder/runtime/qoder-chat-runtime.ts +++ b/src/qoder/runtime/qoder-chat-runtime.ts @@ -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 */ } } @@ -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); } @@ -1034,11 +1032,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); } /** diff --git a/src/qoder/runtime/qoder-message-channel.ts b/src/qoder/runtime/qoder-message-channel.ts index fd7e753..9cbdfda 100644 --- a/src/qoder/runtime/qoder-message-channel.ts +++ b/src/qoder/runtime/qoder-message-channel.ts @@ -27,7 +27,6 @@ export class QoderMessageChannel implements AsyncIterable { private turnActive = false; private closed = false; private resolveNext: ((value: IteratorResult) => void) | null = null; - private currentSessionId: string | null = null; private pendingSteerAborts = 0; private onWarning: (message: string) => void; @@ -35,10 +34,6 @@ export class QoderMessageChannel implements AsyncIterable { this.onWarning = onWarning; } - setSessionId(sessionId: string): void { - this.currentSessionId = sessionId; - } - isTurnActive(): boolean { return this.turnActive; } @@ -232,7 +227,6 @@ export class QoderMessageChannel implements AsyncIterable { content: pending.content, }, parent_tool_use_id: null, - session_id: this.currentSessionId || '', }; } } diff --git a/src/qoder/runtime/qoder-user-message-factory.ts b/src/qoder/runtime/qoder-user-message-factory.ts index fcf895a..338a43b 100644 --- a/src/qoder/runtime/qoder-user-message-factory.ts +++ b/src/qoder/runtime/qoder-user-message-factory.ts @@ -30,7 +30,6 @@ function buildUserContentBlocks(prompt: string, images?: ImageAttachment[]): Use export function buildQoderSDKUserMessage( prompt: string, - sessionId: string, images?: ImageAttachment[], ): SDKUserMessage { if (!images || images.length === 0) { @@ -41,7 +40,6 @@ export function buildQoderSDKUserMessage( content: prompt, }, parent_tool_use_id: null, - session_id: sessionId, uuid: randomUUID(), }; } @@ -53,7 +51,6 @@ export function buildQoderSDKUserMessage( content: buildUserContentBlocks(prompt, images), }, parent_tool_use_id: null, - session_id: sessionId, uuid: randomUUID(), }; } diff --git a/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts b/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts index 3878654..e2dddf2 100644 --- a/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts +++ b/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts @@ -916,16 +916,15 @@ describe('QoderChatRuntime', () => { type: 'user', message: { role: 'user', content: 'Hello Qoder' }, parent_tool_use_id: null, - session_id: '', uuid: expect.any(String), }); }); - it('should include session ID when available', () => { + it('omits the session ID so the daemon can stamp its own', () => { service.setSessionId('session-abc'); const message = (service as any).buildSDKUserMessage('Test'); - expect(message.session_id).toBe('session-abc'); + expect(message.session_id).toBeUndefined(); }); it('should build message with images', () => { diff --git a/tests/unit/qoder/runtime/qoder-message-channel.test.ts b/tests/unit/qoder/runtime/qoder-message-channel.test.ts index 3d4f8c0..3b56403 100644 --- a/tests/unit/qoder/runtime/qoder-message-channel.test.ts +++ b/tests/unit/qoder/runtime/qoder-message-channel.test.ts @@ -65,7 +65,7 @@ describe('QoderMessageChannel', () => { }); describe('enqueue and iteration', () => { - it('merges queued text messages and stamps the session ID', async () => { + it('merges queued text messages without stamping a session ID', async () => { const iterator = channel[Symbol.asyncIterator](); const firstPromise = iterator.next(); @@ -76,12 +76,11 @@ describe('QoderMessageChannel', () => { channel.enqueue(createTextUserMessage('second')); channel.enqueue(createTextUserMessage('third')); - channel.setSessionId('session-abc'); channel.onTurnComplete(); const merged = await iterator.next(); expect(merged.value.message.content).toBe('second\n\nthird'); - expect(merged.value.session_id).toBe('session-abc'); + expect(merged.value.session_id).toBeUndefined(); expect(warnings).toHaveLength(0); }); From 300f83e8ff4311398879258e23ffb653b038c22a Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Fri, 30 Oct 2026 14:33:21 +0800 Subject: [PATCH 4/5] fix(qoder): seed persistent queries with the turn external context Daemon sessions are claimed client-side while opening, so restarting a query inside that window leaves the slot stuck and the next resume fails with SESSION_ALREADY_OPEN. The first turn of a restored session could trigger that restart because the query started without the turn's external context and applyDynamicUpdates immediately restarted it. Pass the paths when starting the query so the launch config already matches. Co-authored-by: QoderAI (Qwen 3.8 Max) --- src/qoder/runtime/qoder-chat-runtime.ts | 6 +++++- .../qoder/runtime/qoder-chat-runtime.test.ts | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/qoder/runtime/qoder-chat-runtime.ts b/src/qoder/runtime/qoder-chat-runtime.ts index a02cbab..74864d3 100644 --- a/src/qoder/runtime/qoder-chat-runtime.ts +++ b/src/qoder/runtime/qoder-chat-runtime.ts @@ -872,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 ); } diff --git a/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts b/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts index e2dddf2..4315ba7 100644 --- a/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts +++ b/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts @@ -309,6 +309,23 @@ describe('QoderChatRuntime', () => { expect(service.isPersistentQueryActive()).toBe(false); }); + it('seeds a newly started persistent query with the turn external context', async () => { + service.setSessionId('test-session'); + const startPersistentQuerySpy = jest + .spyOn(service as any, 'startPersistentQuery') + .mockResolvedValue(undefined); + + await collectChunks(queryText( + 'hello', + undefined, + undefined, + { externalContextPaths: ['/external/a'] }, + )); + + expect(startPersistentQuerySpy).toHaveBeenCalledTimes(1); + expect(startPersistentQuerySpy.mock.calls[0][3]).toEqual(['/external/a']); + }); + it('should close persistent query', () => { service.setSessionId('test-session'); service.closePersistentQuery('test reason'); From 1314c906b79ddf8e7d0be9034f370d279016d0b8 Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Fri, 30 Oct 2026 17:19:32 +0800 Subject: [PATCH 5/5] chore(release): sync the SDK lockfile and document the upgrade Co-authored-by: QoderAI (Qwen 3.8 Max) --- CHANGELOG.md | 5 +++++ package-lock.json | 39 ++++----------------------------------- 2 files changed, 9 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e970d16..5278bb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 0bdc774..ec001e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,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" }, "devDependencies": { @@ -2954,9 +2954,9 @@ } }, "node_modules/@qoder-ai/qoder-agent-sdk": { - "version": "1.0.45", - "resolved": "https://registry.npmjs.org/@qoder-ai/qoder-agent-sdk/-/qoder-agent-sdk-1.0.45.tgz", - "integrity": "sha512-SQFx702DxLiXpbWJw/CtzqG+QYWztRkxcdlS/Ai7Y1SJSesFEWfwMEE7xazoXU89pzMRzE0uvDvcBcB7w4fRCQ==", + "version": "1.0.58", + "resolved": "https://registry.npmjs.org/@qoder-ai/qoder-agent-sdk/-/qoder-agent-sdk-1.0.58.tgz", + "integrity": "sha512-h726yBIXm08GQ9mZ9cLXJJWdVCiCt/ys6JlcJ2Ij7ipQZDS8yrQl0LxEGGJBv9k586oD9VjQMnbuUmK9HNnNVA==", "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { @@ -6832,37 +6832,6 @@ "node": ">=10.13.0" } }, - "node_modules/glob/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", - "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",