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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,24 @@ All notable changes to this project are documented here.

## Unreleased

### Changed

- Depend on `@elgato/streamdeck` 3.0.1.
- Derive Model dial families from `models_cache.json` with the existing
slug and reasoning allow-list, falling back to Luna, Terra, Sol, and Astra
when the cache is missing.
- Prefer `state_5.sqlite` and fall back to the highest-numbered
`state_*.sqlite` when that default file is absent.
- Keep `test:fast` free of native helper spawns, including the Astra picker
fixtures.

### Fixed

- Pin `brace-expansion` to a patched override and update `fast-uri` so
`npm run audit:full` and production High audits pass.
- Map a missing, locked, or incompatible Codex SQLite database to the
existing `UNSUPPORTED` availability state instead of throwing and blanking
keys. Health and `npm run doctor` report the store when it is unreadable.
- Agent Status no longer lists Codex Guardian / Guardian2 reviewer
sub-sessions, which could fill the recent-task slots and fail with
"cannot resume a live Guardian reviewer". Internal threads are excluded
Expand Down
2 changes: 1 addition & 1 deletion MARKETPLACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ It is release evidence, not a substitute for Maker Console review.
- [x] Reverse-DNS plugin UUID includes author and product identity.
- [x] Every action UUID is prefixed by the plugin UUID.
- [x] SDK version 3 and Stream Deck 7.1 minimum.
- [x] Node.js SDK `@elgato/streamdeck` 2.x and Node.js 24 runtime.
- [x] Node.js SDK `@elgato/streamdeck` 3.0.1 and Node.js 24 runtime.
- [x] macOS 13 minimum is declared.
- [x] Plugin and category names are aligned.
- [x] Eleven actions—within Elgato's recommended 2–30 range.
Expand Down
12 changes: 6 additions & 6 deletions package-lock.json

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

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
"openai"
],
"overrides": {
"brace-expansion": "5.0.9",
"brace-expansion": ">=5.0.12",
"fast-uri": "3.1.8",
"nanoid": "3.3.18"
},
"engines": {
Expand Down
186 changes: 168 additions & 18 deletions src/lib/codex-store.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import {
closeSync,
existsSync,
fstatSync,
openSync,
readdirSync,
readFileSync,
readSync,
statSync,
} from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { DatabaseSync, type SQLInputValue } from "node:sqlite";
import type {
AgentSnapshot,
ContextSnapshot,
Expand Down Expand Up @@ -82,7 +84,7 @@ interface SqliteNamedRow {
/** Root user chats use `/root` or leave this unset; nested paths are sub-sessions. */
const ROOT_AGENT_PATH = "/root";

function tableColumns(database: DatabaseSync, table: string): Set<string> {
function tableColumns(database: StoreDatabase, table: string): Set<string> {
return new Set(
(
database
Expand All @@ -94,7 +96,7 @@ function tableColumns(database: DatabaseSync, table: string): Set<string> {
);
}

function inspectThreadSchema(database: DatabaseSync): ThreadSchema {
function inspectThreadSchema(database: StoreDatabase): ThreadSchema {
const columns = tableColumns(database, "threads");
const spawnTable = database
.prepare(
Expand Down Expand Up @@ -194,26 +196,48 @@ export function resolveCodexHome(): string {
return resolve(expandHome(configured || join(homedir(), ".codex")));
}

const STATE_SQLITE_NAME = /^state_(\d+)\.sqlite$/;
const DEFAULT_STATE_SQLITE = "state_5.sqlite";

function resolveSqliteInDirectory(directory: string): string {
const preferred = join(directory, DEFAULT_STATE_SQLITE);
if (existsSync(preferred)) return preferred;
try {
let highest: { name: string; generation: number } | undefined;
for (const name of readdirSync(directory)) {
const match = STATE_SQLITE_NAME.exec(name);
if (!match) continue;
const generation = Number(match[1]);
if (!highest || generation > highest.generation) {
highest = { name, generation };
}
}
if (highest) return join(directory, highest.name);
} catch {
// An unreadable directory still resolves to the default filename.
}
return preferred;
}

export function resolveStateDatabase(codexHome = resolveCodexHome()): string {
const explicit = process.env["CODEX_SQLITE_HOME"];
if (explicit) {
const expanded = resolve(expandHome(explicit));
return expanded.endsWith(".sqlite")
? expanded
: join(expanded, "state_5.sqlite");
: resolveSqliteInDirectory(expanded);
}

try {
const config = readFileSync(join(codexHome, "config.toml"), "utf8");
const match = config.match(/^\s*sqlite_home\s*=\s*["']([^"']+)["']/m);
if (match?.[1]) {
const configuredHome = resolve(expandHome(match[1]));
return join(configuredHome, "state_5.sqlite");
return resolveSqliteInDirectory(resolve(expandHome(match[1])));
}
} catch {
// The default location remains authoritative when no readable config exists.
}
return join(codexHome, "state_5.sqlite");
return resolveSqliteInDirectory(codexHome);
}

export function readFileTail(path: string, maxBytes = 512 * 1024): string {
Expand Down Expand Up @@ -315,11 +339,81 @@ function sameFile(
);
}

interface StoreStatement {
all: (...params: SQLInputValue[]) => unknown;
get: (...params: SQLInputValue[]) => unknown;
}

interface StoreDatabase {
prepare(sql: string): StoreStatement;
exec(sql: string): void;
close(): void;
}

const UNAVAILABLE_STORE_DATABASE: StoreDatabase = {
prepare() {
return { all: () => [], get: () => undefined };
},
exec() {},
close() {},
};

const EMPTY_THREAD_SCHEMA: ThreadSchema = {
columns: new Set(),
spawnColumns: new Set(),
};

function guardedStoreDatabase(
database: DatabaseSync,
onFailure: (error: unknown) => void,
): StoreDatabase {
const statement = (sql: string): StoreStatement => {
try {
const prepared = database.prepare(sql);
return {
all: (...params: SQLInputValue[]) => {
try {
return prepared.all(...params);
} catch (error) {
onFailure(error);
return [];
}
},
get: (...params: SQLInputValue[]) => {
try {
return prepared.get(...params);
} catch (error) {
onFailure(error);
return undefined;
}
},
};
} catch (error) {
onFailure(error);
return { all: () => [], get: () => undefined };
}
};
return {
prepare: statement,
exec(sql: string) {
try {
database.exec(sql);
} catch (error) {
onFailure(error);
}
},
close() {
database.close();
},
};
}

export class CodexStore {
readonly codexHome: string;
readonly databasePath: string;
readonly #acknowledged = new Map<string, number>();
#database: DatabaseSync | undefined;
#database: StoreDatabase | undefined;
#databaseUnavailableReason: AvailabilityReason | undefined;
#cache: { at: number; snapshots: AgentSnapshot[] } | undefined;
#usageCache:
| { at: number; snapshot?: UsageSnapshot; reason?: AvailabilityReason }
Expand Down Expand Up @@ -401,9 +495,21 @@ export class CodexStore {
close(): void {
this.#database?.close();
this.#database = undefined;
this.#databaseUnavailableReason = undefined;
this.#threadSchema = undefined;
}

/**
* Cheap local SQLite readability. Failures are `unsupported-schema` so
* Health and doctor can render the existing UNSUPPORTED label.
*/
storeAvailability(): Availability<string> {
this.#probeDatabase();
return this.#databaseUnavailableReason
? unavailable(this.#databaseUnavailableReason)
: ready("readable");
}

acknowledge(threadId: string, at = Date.now()): void {
this.#acknowledged.set(threadId, at);
this.#cache = undefined;
Expand Down Expand Up @@ -924,19 +1030,63 @@ export class CodexStore {

#schema(): ThreadSchema {
this.#open();
return this.#threadSchema!;
return this.#threadSchema ?? EMPTY_THREAD_SCHEMA;
}

#markDatabaseUnavailable(): void {
this.#databaseUnavailableReason = "unsupported-schema";
this.#threadSchema = undefined;
const database = this.#database;
this.#database = undefined;
try {
database?.close();
} catch {
// Closing a failed handle must not hide the original unavailability.
}
}

#probeDatabase(): void {
const database = this.#open();
if (this.#databaseUnavailableReason) return;
database.prepare(threadSelectSql(this.#schema(), "recent")).all(0);
}

#open(): DatabaseSync {
#open(): StoreDatabase {
if (this.#database) return this.#database;
const database = new DatabaseSync(this.databasePath, {
readOnly: true,
timeout: 1000,
});
database.exec("PRAGMA query_only = ON");
this.#database = database;
this.#threadSchema = inspectThreadSchema(database);
return database;
if (!existsSync(this.databasePath)) {
this.#markDatabaseUnavailable();
return UNAVAILABLE_STORE_DATABASE;
}
try {
const database = new DatabaseSync(this.databasePath, {
readOnly: true,
timeout: 1000,
});
try {
database.exec("PRAGMA query_only = ON");
const guarded = guardedStoreDatabase(database, () => {
this.#markDatabaseUnavailable();
});
this.#database = guarded;
this.#databaseUnavailableReason = undefined;
const schema = inspectThreadSchema(guarded);
if (this.#databaseUnavailableReason) {
return UNAVAILABLE_STORE_DATABASE;
}
this.#threadSchema = schema;
return guarded;
} catch (error) {
try {
database.close();
} catch {
// Prefer the original open/exec failure.
}
throw error;
}
} catch {
this.#markDatabaseUnavailable();
return UNAVAILABLE_STORE_DATABASE;
}
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/lib/health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const HEALTH_COMPONENTS = [
"reasoning",
"usage",
"input",
"store",
] as const;

export type HealthComponent = (typeof HEALTH_COMPONENTS)[number];
Expand Down Expand Up @@ -68,6 +69,7 @@ export function collectHealth(store: CodexStore): HealthSnapshot {
(snapshot) => `${Math.round(100 - snapshot.usedPercent)}% left`,
),
input: inputHealth,
store: summarize(store.storeAvailability(), (value) => value),
},
};
}
Expand Down
Loading
Loading