diff --git a/src/npx-cli/commands/install.ts b/src/npx-cli/commands/install.ts index 673f4479ef..a42cf9ebb0 100644 --- a/src/npx-cli/commands/install.ts +++ b/src/npx-cli/commands/install.ts @@ -1603,7 +1603,6 @@ async function runInstallCommandInner(options: InstallOptions, summary: InstallS } finally { stopHeartbeat(); } - writeInstallMarker(cacheDir, version, bunVersion, uvVersion); } return `Runtime ready (Bun ${bunVersion}, uv ${uvVersion}) ${pc.green('OK')}`; }, @@ -1638,6 +1637,13 @@ async function runInstallCommandInner(options: InstallOptions, summary: InstallS } await runTasks(tasks); + + if (installedBunVersion && installedUvVersion) { + writeInstallMarker(cacheDir, version, installedBunVersion, installedUvVersion); + if (needsMarketplace && existsSync(join(marketplaceDirectory(), 'plugin', 'package.json'))) { + writeInstallMarker(marketplaceDirectory(), version, installedBunVersion, installedUvVersion); + } + } } const failedIDEs = await setupIDEs(selectedIDEs, summary); @@ -1908,6 +1914,9 @@ export async function runRepairCommand(): Promise { const { bunPath } = await ensureBun(); await installPluginDependencies(cacheDir, bunPath); writeInstallMarker(cacheDir, version, bunVersion, uvVersion); + if (existsSync(join(marketplaceDirectory(), 'plugin', 'package.json'))) { + writeInstallMarker(marketplaceDirectory(), version, bunVersion, uvVersion); + } return `Runtime ready (Bun ${bunVersion}, uv ${uvVersion}) ${pc.green('OK')}`; }, }, diff --git a/src/npx-cli/install/setup-runtime.ts b/src/npx-cli/install/setup-runtime.ts index d481068fe2..283201d004 100644 --- a/src/npx-cli/install/setup-runtime.ts +++ b/src/npx-cli/install/setup-runtime.ts @@ -67,7 +67,15 @@ const LEGACY_VERSION_MARKER_RE = /^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; function markerPath(targetDir: string): string { - return join(targetDir, '.install-version'); + return join(resolveInstallTargetDir(targetDir), '.install-version'); +} + +function resolveInstallTargetDir(targetDir: string): string { + const bundledPluginRoot = join(targetDir, 'plugin'); + if (existsSync(join(bundledPluginRoot, 'package.json'))) { + return bundledPluginRoot; + } + return targetDir; } function getBunPath(): string | null { @@ -467,7 +475,8 @@ export function writeInstallMarker( } export function isInstallCurrent(targetDir: string, expectedVersion: string): boolean { - if (!existsSync(join(targetDir, 'node_modules'))) return false; + const installDir = resolveInstallTargetDir(targetDir); + if (!existsSync(join(installDir, 'node_modules'))) return false; const marker = readInstallMarker(targetDir); if (!marker) return false; if (marker.version !== expectedVersion) return false; diff --git a/src/services/sqlite/SessionStore.ts b/src/services/sqlite/SessionStore.ts index 8680747736..74b8dd719d 100644 --- a/src/services/sqlite/SessionStore.ts +++ b/src/services/sqlite/SessionStore.ts @@ -460,8 +460,6 @@ export class SessionStore { CREATE INDEX IF NOT EXISTS idx_sdk_sessions_project ON sdk_sessions(project); CREATE INDEX IF NOT EXISTS idx_sdk_sessions_status ON sdk_sessions(status); CREATE INDEX IF NOT EXISTS idx_sdk_sessions_started ON sdk_sessions(started_at_epoch DESC); - CREATE INDEX IF NOT EXISTS idx_sdk_sessions_platform_source ON sdk_sessions(platform_source); - CREATE UNIQUE INDEX IF NOT EXISTS ux_sdk_sessions_platform_content ON sdk_sessions(platform_source, content_session_id); CREATE TABLE IF NOT EXISTS observations ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -501,6 +499,13 @@ export class SessionStore { CREATE INDEX IF NOT EXISTS idx_session_summaries_created ON session_summaries(created_at_epoch DESC); `); + const sdkSessionColumns = this.db.query('PRAGMA table_info(sdk_sessions)').all() as TableColumnInfo[]; + const hasPlatformSource = sdkSessionColumns.some(col => col.name === 'platform_source'); + if (hasPlatformSource) { + this.db.run('CREATE INDEX IF NOT EXISTS idx_sdk_sessions_platform_source ON sdk_sessions(platform_source)'); + this.db.run('CREATE UNIQUE INDEX IF NOT EXISTS ux_sdk_sessions_platform_content ON sdk_sessions(platform_source, content_session_id)'); + } + this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(4, new Date().toISOString()); } diff --git a/tests/install-non-tty.test.ts b/tests/install-non-tty.test.ts index c8f20d722b..910fb37d13 100644 --- a/tests/install-non-tty.test.ts +++ b/tests/install-non-tty.test.ts @@ -147,6 +147,18 @@ describe('Install Non-TTY Support', () => { expect(installSource).toContain('installCodexCli(marketplaceDirectory())'); }); + it('writes the install marker into the durable marketplace plugin root for Codex hook launches', () => { + expect(installSource).toContain('writeInstallMarker(marketplaceDirectory(), version, bunVersion, uvVersion)'); + }); + + it('repairs the durable marketplace plugin root marker as well as the cache copy', () => { + const repairRegion = installSource.slice( + installSource.indexOf('export async function runRepairCommand'), + installSource.indexOf('if (isInteractive) {\n p.outro'), + ); + expect(repairRegion).toContain('writeInstallMarker(marketplaceDirectory(), version, bunVersion, uvVersion)'); + }); + it('refreshes Codex marketplace cache after registration', () => { const installRegion = codexInstallerSource.slice( codexInstallerSource.indexOf('export async function installCodexCli'), diff --git a/tests/setup-runtime.test.ts b/tests/setup-runtime.test.ts index 2a282fcbd6..6d1bdecc58 100644 --- a/tests/setup-runtime.test.ts +++ b/tests/setup-runtime.test.ts @@ -72,6 +72,24 @@ describe('setup-runtime install marker', () => { const marker = readInstallMarker(tempDir); expect(marker).toEqual({ version: '12.4.4' }); }); + + it('reads the marker from plugin/.install-version when given a marketplace root', () => { + mkdirSync(join(tempDir, 'plugin'), { recursive: true }); + writeFileSync(join(tempDir, 'plugin', 'package.json'), JSON.stringify({ name: 'claude-mem' })); + writeFileSync( + join(tempDir, 'plugin', '.install-version'), + JSON.stringify({ version: '13.9.2', bun: '1.3.11', uv: '0.11.14', installedAt: '2026-07-01T00:00:00.000Z' }), + ); + + const marker = readInstallMarker(tempDir); + + expect(marker).toEqual({ + version: '13.9.2', + bun: '1.3.11', + uv: '0.11.14', + installedAt: '2026-07-01T00:00:00.000Z', + }); + }); }); describe('writeInstallMarker', () => { @@ -94,6 +112,16 @@ describe('setup-runtime install marker', () => { const parsed = JSON.parse(readFileSync(join(tempDir, '.install-version'), 'utf-8')); expect(Object.keys(parsed).sort()).toEqual(['bun', 'installedAt', 'uv', 'version'].sort()); }); + + it('writes the marker into plugin/.install-version when targetDir is a marketplace root', () => { + mkdirSync(join(tempDir, 'plugin'), { recursive: true }); + writeFileSync(join(tempDir, 'plugin', 'package.json'), JSON.stringify({ name: 'claude-mem' })); + + writeInstallMarker(tempDir, '13.9.2', '1.3.11', '0.11.14'); + + expect(existsSync(join(tempDir, 'plugin', '.install-version'))).toBe(true); + expect(existsSync(join(tempDir, '.install-version'))).toBe(false); + }); }); describe('isInstallCurrent', () => { diff --git a/tests/sqlite/session-store-migrations.test.ts b/tests/sqlite/session-store-migrations.test.ts index 9b1d88c10e..8841e9f228 100644 --- a/tests/sqlite/session-store-migrations.test.ts +++ b/tests/sqlite/session-store-migrations.test.ts @@ -248,6 +248,132 @@ function seedLegacyGlobalContentIdentityScenario(db: Database): void { `).run(101, 'shared-raw-id', 'tool-1', epoch + 4); } +function seedPrePlatformSourceSdkSessionsScenario(db: Database): void { + const now = new Date().toISOString(); + const epoch = Date.now(); + + db.run(` + CREATE TABLE schema_versions ( + id INTEGER PRIMARY KEY, + version INTEGER UNIQUE NOT NULL, + applied_at TEXT NOT NULL + ) + `); + + db.run(` + CREATE TABLE sdk_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + content_session_id TEXT UNIQUE NOT NULL, + memory_session_id TEXT UNIQUE, + project TEXT NOT NULL, + user_prompt TEXT, + started_at TEXT NOT NULL, + started_at_epoch INTEGER NOT NULL, + completed_at TEXT, + completed_at_epoch INTEGER, + status TEXT CHECK(status IN ('active', 'completed', 'failed')) NOT NULL DEFAULT 'active', + worker_port INTEGER, + prompt_counter INTEGER DEFAULT 0, + custom_title TEXT + ) + `); + + db.run(` + CREATE TABLE observations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + memory_session_id TEXT NOT NULL, + project TEXT NOT NULL, + text TEXT, + type TEXT NOT NULL, + title TEXT, + subtitle TEXT, + facts TEXT, + narrative TEXT, + concepts TEXT, + files_read TEXT, + files_modified TEXT, + prompt_number INTEGER, + discovery_tokens INTEGER DEFAULT 0, + content_hash TEXT, + agent_type TEXT, + agent_id TEXT, + merged_into_project TEXT, + generated_by_model TEXT, + metadata TEXT, + created_at TEXT NOT NULL, + created_at_epoch INTEGER NOT NULL, + FOREIGN KEY(memory_session_id) REFERENCES sdk_sessions(memory_session_id) ON DELETE CASCADE ON UPDATE CASCADE + ) + `); + + db.run(` + CREATE TABLE session_summaries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + memory_session_id TEXT NOT NULL, + project TEXT NOT NULL, + request TEXT, + investigated TEXT, + learned TEXT, + completed TEXT, + next_steps TEXT, + files_read TEXT, + files_edited TEXT, + notes TEXT, + prompt_number INTEGER, + discovery_tokens INTEGER DEFAULT 0, + merged_into_project TEXT, + created_at TEXT NOT NULL, + created_at_epoch INTEGER NOT NULL, + FOREIGN KEY(memory_session_id) REFERENCES sdk_sessions(memory_session_id) ON DELETE CASCADE ON UPDATE CASCADE + ) + `); + + db.run(` + CREATE TABLE user_prompts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + content_session_id TEXT NOT NULL, + prompt_number INTEGER NOT NULL, + prompt_text TEXT NOT NULL, + created_at TEXT NOT NULL, + created_at_epoch INTEGER NOT NULL, + FOREIGN KEY(content_session_id) REFERENCES sdk_sessions(content_session_id) ON DELETE CASCADE + ) + `); + + db.run(` + CREATE TABLE pending_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_db_id INTEGER NOT NULL, + content_session_id TEXT NOT NULL, + tool_use_id TEXT, + message_type TEXT NOT NULL CHECK(message_type IN ('observation', 'summarize')), + tool_name TEXT, + tool_input TEXT, + tool_response TEXT, + cwd TEXT, + last_user_message TEXT, + last_assistant_message TEXT, + prompt_number INTEGER, + status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'processing')), + created_at_epoch INTEGER NOT NULL, + agent_type TEXT, + agent_id TEXT, + FOREIGN KEY (session_db_id) REFERENCES sdk_sessions(id) ON DELETE CASCADE + ) + `); + + for (let version = 4; version <= 23; version++) { + db.prepare('INSERT INTO schema_versions (version, applied_at) VALUES (?, ?)').run(version, now); + } + + db.prepare(` + INSERT INTO sdk_sessions ( + content_session_id, memory_session_id, project, user_prompt, + started_at, started_at_epoch, status, worker_port, prompt_counter, custom_title + ) VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?) + `).run('legacy-content-id', 'legacy-memory-id', 'legacy-project', 'legacy prompt', now, epoch, 37777, 1, 'Legacy Title'); +} + describe('SessionStore migrations', () => { let store: SessionStore | undefined; @@ -395,6 +521,39 @@ describe('SessionStore migrations', () => { } }); + it('bootstraps a pre-platform_source sdk_sessions table without crashing initializeSchema', () => { + const db = new Database(':memory:'); + try { + seedPrePlatformSourceSdkSessionsScenario(db); + + expect(() => new SessionStore(db)).not.toThrow(); + + const cols = new Set((db.query('PRAGMA table_info(sdk_sessions)').all() as Array<{ name: string }>).map(c => c.name)); + expect(cols.has('platform_source')).toBe(true); + expect(hasUniqueIndexOnColumns(db, 'sdk_sessions', ['platform_source', 'content_session_id'])).toBe(true); + + const row = db.prepare(` + SELECT content_session_id, memory_session_id, project, platform_source, worker_port, prompt_counter, custom_title + FROM sdk_sessions + WHERE content_session_id = 'legacy-content-id' + `).get() as { + content_session_id: string; + memory_session_id: string; + project: string; + platform_source: string; + worker_port: number; + prompt_counter: number; + custom_title: string | null; + }; + expect(row.platform_source).toBe('claude'); + expect(row.worker_port).toBe(37777); + expect(row.prompt_counter).toBe(1); + expect(row.custom_title).toBe('Legacy Title'); + } finally { + db.close(); + } + }); + it('drops the dead pending_messages columns (retry_count / failed_at_epoch / completed_at_epoch / worker_pid) on a legacy db', () => { const db = new Database(':memory:'); try {