@scotthuang/agent-knock-knock 0.11.0 → 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,41 +1,56 @@
1
- import { createHash } from "node:crypto";
1
+ import { spawn } from "node:child_process";
2
+ import { createHash, randomUUID } from "node:crypto";
2
3
  import fs from "node:fs";
3
4
  import os from "node:os";
4
5
  import path from "node:path";
6
+ import { pathToFileURL } from "node:url";
5
7
  import { codexLifecycleBehaviorProfile, supportedCodexLifecycleVersions } from "./codex-lifecycle-compatibility.js";
6
8
  import { discoverCodexProcesses } from "./codex-session-provider.js";
7
9
  import { SystemTerminalProcessSource, runProcessCommand } from "./terminal-process-source.js";
8
10
  export { parseLsofCwdMap, parsePsProcessSnapshots } from "./terminal-process-source.js";
9
11
  const NATIVE_THREAD_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
10
12
  const MAX_CODEX_SESSION_META_BYTES = 1024 * 1024;
13
+ const MAX_SQLITE_QUERY_OUTPUT_BYTES = 10 * 1024 * 1024;
14
+ const MAX_SQLITE_ERROR_OUTPUT_BYTES = 1024 * 1024;
15
+ const SQLITE_QUERY_TIMEOUT_MS = 10_000;
16
+ const DEFAULT_SQLITE_CANTOPEN_RETRY_DELAYS_MS = [25, 75, 150];
11
17
  const NO_FOLLOW_FLAG = typeof fs.constants.O_NOFOLLOW === "number"
12
18
  ? fs.constants.O_NOFOLLOW
13
19
  : 0;
14
20
  export class CodexStoreAdapter {
15
21
  codexHome;
16
22
  runCommand;
23
+ runSqliteThreadQuery;
24
+ sqliteCantOpenRetryDelaysMs;
25
+ sleep;
17
26
  maxSessions;
18
27
  constructor(options = {}) {
19
28
  this.codexHome = options.codexHome ?? path.join(os.homedir(), ".codex");
20
29
  this.runCommand = options.runCommand ?? runProcessCommand;
30
+ this.runSqliteThreadQuery = options.runSqliteThreadQuery ??
31
+ runCodexSqliteThreadQuery;
32
+ this.sqliteCantOpenRetryDelaysMs =
33
+ options.sqliteCantOpenRetryDelaysMs ??
34
+ DEFAULT_SQLITE_CANTOPEN_RETRY_DELAYS_MS;
35
+ this.sleep = options.sleep ?? waitForMilliseconds;
21
36
  this.maxSessions = options.maxSessions ?? 100;
22
37
  }
23
38
  async listThreadRows() {
24
- const dbPath = latestStateDbPath(this.codexHome);
25
- if (!dbPath) {
26
- throw new Error("no Codex state sqlite database found");
27
- }
28
- const columns = this.queryJson(dbPath, "pragma table_info(threads)")
29
- .map((column) => column.name);
30
- if (!columns.includes("id") || !columns.includes("cwd")) {
31
- throw new Error("Codex threads table is missing required id or cwd columns");
32
- }
33
- return this.queryJson(dbPath, buildThreadSelect(columns, this.maxSessions));
39
+ return this.queryThreadRows({ maxSessions: this.maxSessions });
34
40
  }
35
41
  async listThreadLifecycleCandidates(request) {
36
42
  assertCodexLifecycleCandidateRequest(request);
37
43
  const candidates = [];
38
- for (const row of await this.listThreadRows()) {
44
+ const rows = await this.queryThreadRows({
45
+ maxSessions: this.maxSessions,
46
+ filters: {
47
+ cwd: path.resolve(request.cwd),
48
+ source: "cli",
49
+ archived: false,
50
+ modelProvider: request.modelProvider
51
+ }
52
+ });
53
+ for (const row of rows) {
39
54
  try {
40
55
  const candidate = codexLifecycleCandidateFromRow({
41
56
  row,
@@ -60,7 +75,11 @@ export class CodexStoreAdapter {
60
75
  ? candidate.candidateToken
61
76
  : candidate;
62
77
  if (token.schema !== "agent-knock-knock/thread-candidate-token" ||
63
- token.version !== 1 ||
78
+ ![1, 2].includes(token.version) ||
79
+ (token.version === 1
80
+ ? "sourceAgentVersion" in token
81
+ : (!stringField(token.sourceAgentVersion) ||
82
+ token.sourceAgentVersion === token.agentVersion)) ||
64
83
  token.agent !== "codex" ||
65
84
  token.source !== "codex_rollout" ||
66
85
  token.agentVersion !== request.agentVersion ||
@@ -92,6 +111,7 @@ export class CodexStoreAdapter {
92
111
  }
93
112
  if (!sameThreadFileToken(current.fileToken, token.fileToken) ||
94
113
  current.metadataFingerprint !== token.metadataFingerprint ||
114
+ current.sourceAgentVersion !== candidateSourceAgentVersion(token) ||
95
115
  current.modelProvider !== token.modelProvider) {
96
116
  return {
97
117
  status: "changed",
@@ -112,15 +132,10 @@ export class CodexStoreAdapter {
112
132
  if (!NATIVE_THREAD_ID_PATTERN.test(nativeThreadId)) {
113
133
  throw new Error("Codex thread lookup requires an exact UUID");
114
134
  }
115
- const dbPath = latestStateDbPath(this.codexHome);
116
- if (!dbPath) {
117
- return undefined;
118
- }
119
- const columns = this.queryJson(dbPath, "pragma table_info(threads)").map((column) => column.name);
120
- if (!columns.includes("id") || !columns.includes("cwd")) {
121
- throw new Error("Codex threads table is missing required id or cwd columns");
122
- }
123
- return this.queryJson(dbPath, buildThreadByIdSelect(columns, nativeThreadId))[0];
135
+ return (await this.queryThreadRows({
136
+ maxSessions: 1,
137
+ nativeThreadId
138
+ }))[0];
124
139
  }
125
140
  async readRollout(rolloutPath) {
126
141
  if (!fs.existsSync(rolloutPath)) {
@@ -163,14 +178,438 @@ export class CodexStoreAdapter {
163
178
  lsofOutput: result.stdout
164
179
  });
165
180
  }
166
- queryJson(dbPath, sql) {
167
- const result = this.runCommand("sqlite3", ["-readonly", "-json", dbPath, sql]);
168
- if (result.status !== 0) {
169
- throw new Error(result.stderr || result.error?.message || "sqlite3 query failed");
181
+ async queryThreadRows({ maxSessions, nativeThreadId, filters }) {
182
+ const dbPath = latestStateDbPath(this.codexHome);
183
+ if (!dbPath) {
184
+ throw new Error("no Codex state sqlite database found");
185
+ }
186
+ const baseline = inspectCodexSqliteFiles(dbPath);
187
+ assertStableCodexSqliteMain({
188
+ baseline,
189
+ current: baseline,
190
+ stage: "initial"
191
+ });
192
+ let lastFailure;
193
+ const attempts = this.sqliteCantOpenRetryDelaysMs.length + 1;
194
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
195
+ if (attempt > 0) {
196
+ await this.sleep(this.sqliteCantOpenRetryDelaysMs[attempt - 1]);
197
+ }
198
+ const currentPath = latestStateDbPath(this.codexHome);
199
+ const before = inspectCodexSqliteFiles(currentPath ?? dbPath);
200
+ assertStableCodexSqliteMain({
201
+ baseline,
202
+ current: before,
203
+ stage: `readonly_attempt_${attempt + 1}`,
204
+ selectedPath: currentPath
205
+ });
206
+ let result;
207
+ try {
208
+ result = await this.runSqliteThreadQuery({
209
+ dbPath,
210
+ openMode: "readonly",
211
+ maxSessions,
212
+ nativeThreadId,
213
+ filters
214
+ });
215
+ }
216
+ catch (error) {
217
+ const failedPath = latestStateDbPath(this.codexHome);
218
+ const failedFiles = inspectCodexSqliteFiles(failedPath ?? dbPath);
219
+ assertStableCodexSqliteMain({
220
+ baseline,
221
+ current: failedFiles,
222
+ stage: `readonly_attempt_${attempt + 1}_failed`,
223
+ selectedPath: failedPath
224
+ });
225
+ const failure = codexSqliteQueryFailure(error, {
226
+ dbPath,
227
+ stage: `readonly_attempt_${attempt + 1}`,
228
+ files: failedFiles
229
+ });
230
+ if (!isSqliteCantOpen(failure)) {
231
+ throw codexSqliteQueryDiagnosticError(failure);
232
+ }
233
+ lastFailure = failure;
234
+ continue;
235
+ }
236
+ const completedPath = latestStateDbPath(this.codexHome);
237
+ assertStableCodexSqliteMain({
238
+ baseline,
239
+ current: inspectCodexSqliteFiles(completedPath ?? dbPath),
240
+ stage: `readonly_attempt_${attempt + 1}_complete`,
241
+ selectedPath: completedPath
242
+ });
243
+ return validateCodexThreadQueryResult(result);
244
+ }
245
+ const currentPath = latestStateDbPath(this.codexHome);
246
+ const beforeMaterialization = inspectCodexSqliteFiles(currentPath ?? dbPath);
247
+ assertStableCodexSqliteMain({
248
+ baseline,
249
+ current: beforeMaterialization,
250
+ stage: "query_only_materialization",
251
+ selectedPath: currentPath
252
+ });
253
+ let materializedResult;
254
+ try {
255
+ materializedResult = await this.runSqliteThreadQuery({
256
+ dbPath,
257
+ openMode: "query_only",
258
+ maxSessions,
259
+ nativeThreadId,
260
+ filters
261
+ });
170
262
  }
171
- return JSON.parse(result.stdout || "[]");
263
+ catch (error) {
264
+ const failedPath = latestStateDbPath(this.codexHome);
265
+ const failedFiles = inspectCodexSqliteFiles(failedPath ?? dbPath);
266
+ assertStableCodexSqliteMain({
267
+ baseline,
268
+ current: failedFiles,
269
+ stage: "query_only_materialization_failed",
270
+ selectedPath: failedPath
271
+ });
272
+ throw codexSqliteQueryDiagnosticError(codexSqliteQueryFailure(error, {
273
+ dbPath,
274
+ stage: "query_only_materialization",
275
+ files: failedFiles,
276
+ previousFailure: lastFailure
277
+ }));
278
+ }
279
+ const completedPath = latestStateDbPath(this.codexHome);
280
+ assertStableCodexSqliteMain({
281
+ baseline,
282
+ current: inspectCodexSqliteFiles(completedPath ?? dbPath),
283
+ stage: "query_only_materialization_complete",
284
+ selectedPath: completedPath
285
+ });
286
+ return validateCodexThreadQueryResult(materializedResult);
172
287
  }
173
288
  }
289
+ class CodexSqliteSessionError extends Error {
290
+ status;
291
+ stage;
292
+ constructor({ message, status, stage }) {
293
+ super(message);
294
+ this.name = "CodexSqliteSessionError";
295
+ this.status = status;
296
+ this.stage = stage;
297
+ }
298
+ }
299
+ export async function runCodexSqliteThreadQuery(request) {
300
+ if (request.nativeThreadId && request.filters) {
301
+ throw new Error("Codex thread query cannot combine an exact UUID with lifecycle filters");
302
+ }
303
+ if (request.nativeThreadId &&
304
+ !NATIVE_THREAD_ID_PATTERN.test(request.nativeThreadId)) {
305
+ throw new Error("Codex thread lookup requires an exact UUID");
306
+ }
307
+ if (request.filters?.cwd && !path.isAbsolute(request.filters.cwd)) {
308
+ throw new Error("Codex lifecycle thread query requires an absolute cwd");
309
+ }
310
+ const nonce = randomUUID();
311
+ const controlColumn = "__akk_sqlite_control";
312
+ const schemaControl = `schema:${nonce}`;
313
+ const rowsControl = `rows:${nonce}`;
314
+ const schemaMarker = JSON.stringify([{ [controlColumn]: schemaControl }]);
315
+ const rowsMarker = JSON.stringify([{ [controlColumn]: rowsControl }]);
316
+ const databaseArgument = request.openMode === "query_only"
317
+ ? sqliteReadWriteUri(request.dbPath)
318
+ : request.dbPath;
319
+ const args = ["-batch", "-bail", "-json"];
320
+ if (request.openMode === "readonly") {
321
+ args.push("-readonly");
322
+ }
323
+ const parameterCommands = sqliteThreadFilterParameterCommands(request.filters);
324
+ for (const command of parameterCommands) {
325
+ args.push("-cmd", command);
326
+ }
327
+ if (request.openMode === "query_only") {
328
+ // SQLite may materialize WAL/SHM bookkeeping on this mode=rw connection,
329
+ // while query_only forbids business SQL writes for the AKK session.
330
+ // Parameter initialization, when needed, only creates a TEMP table and
331
+ // must precede query_only because SQLite also applies it to TEMP writes.
332
+ args.push("-cmd", "PRAGMA query_only=ON");
333
+ }
334
+ args.push(databaseArgument);
335
+ return new Promise((resolve, reject) => {
336
+ const child = spawn("sqlite3", args, {
337
+ stdio: ["pipe", "pipe", "pipe"]
338
+ });
339
+ let phase = "schema";
340
+ let output = "";
341
+ let stderr = "";
342
+ let result;
343
+ let authoritativeColumns = [];
344
+ let terminalError;
345
+ let settled = false;
346
+ const timeout = setTimeout(() => {
347
+ if (!terminalError) {
348
+ terminalError = new CodexSqliteSessionError({
349
+ message: `sqlite3 thread query timed out after ${SQLITE_QUERY_TIMEOUT_MS}ms`,
350
+ status: null,
351
+ stage: phase
352
+ });
353
+ }
354
+ child.kill("SIGKILL");
355
+ }, SQLITE_QUERY_TIMEOUT_MS);
356
+ const stopWithError = (error) => {
357
+ if (!terminalError) {
358
+ terminalError = error instanceof CodexSqliteSessionError
359
+ ? error
360
+ : new CodexSqliteSessionError({
361
+ message: error.message,
362
+ status: null,
363
+ stage: phase
364
+ });
365
+ }
366
+ child.kill("SIGKILL");
367
+ };
368
+ const appendOutput = (current, chunk, limit) => {
369
+ const next = current + chunk;
370
+ if (Buffer.byteLength(next, "utf8") > limit) {
371
+ throw new Error(`sqlite3 ${phase} output exceeded ${limit} bytes`);
372
+ }
373
+ return next;
374
+ };
375
+ const parseArray = (text, label) => {
376
+ if (!text.trim()) {
377
+ return [];
378
+ }
379
+ const parsed = JSON.parse(text);
380
+ if (!Array.isArray(parsed)) {
381
+ throw new Error(`sqlite3 ${label} output was not a JSON array`);
382
+ }
383
+ return parsed;
384
+ };
385
+ const writeRowsQuery = (columns) => {
386
+ validateCodexThreadColumns(columns);
387
+ authoritativeColumns = columns;
388
+ const sql = request.nativeThreadId
389
+ ? buildThreadByIdSelect(columns, request.nativeThreadId)
390
+ : buildThreadSelect(columns, request.maxSessions, request.filters);
391
+ phase = "rows";
392
+ child.stdin.write(`${sql};\nselect '${rowsControl}' as "${controlColumn}";\n`);
393
+ };
394
+ const consumeOutput = () => {
395
+ if (phase === "schema") {
396
+ const markerIndex = output.indexOf(schemaMarker);
397
+ if (markerIndex < 0) {
398
+ return;
399
+ }
400
+ const schema = parseArray(output.slice(0, markerIndex).trim(), "schema");
401
+ const columns = schema
402
+ .map((column) => typeof column.name === "string" ? column.name : "")
403
+ .filter(Boolean);
404
+ output = output.slice(markerIndex + schemaMarker.length).trimStart();
405
+ phase = "schema_hook";
406
+ void Promise.resolve(request.afterSchema?.(columns))
407
+ .then(() => writeRowsQuery(columns))
408
+ .catch((error) => stopWithError(error instanceof Error ? error : new Error(String(error))));
409
+ }
410
+ if (phase === "rows") {
411
+ const markerIndex = output.indexOf(rowsMarker);
412
+ if (markerIndex < 0) {
413
+ return;
414
+ }
415
+ const rows = parseArray(output.slice(0, markerIndex).trim(), "rows");
416
+ result = {
417
+ columns: authoritativeColumns,
418
+ rows
419
+ };
420
+ phase = "complete";
421
+ child.stdin.end("COMMIT;\n.quit\n");
422
+ }
423
+ };
424
+ child.stdout.setEncoding("utf8");
425
+ child.stderr.setEncoding("utf8");
426
+ child.stdout.on("data", (chunk) => {
427
+ try {
428
+ output = appendOutput(output, chunk, MAX_SQLITE_QUERY_OUTPUT_BYTES);
429
+ consumeOutput();
430
+ }
431
+ catch (error) {
432
+ stopWithError(error instanceof Error ? error : new Error(String(error)));
433
+ }
434
+ });
435
+ child.stderr.on("data", (chunk) => {
436
+ try {
437
+ stderr = appendOutput(stderr, chunk, MAX_SQLITE_ERROR_OUTPUT_BYTES);
438
+ }
439
+ catch (error) {
440
+ stopWithError(error instanceof Error ? error : new Error(String(error)));
441
+ }
442
+ });
443
+ child.on("error", (error) => {
444
+ if (!terminalError) {
445
+ terminalError = new CodexSqliteSessionError({
446
+ message: error.message,
447
+ status: null,
448
+ stage: phase
449
+ });
450
+ }
451
+ });
452
+ child.stdin.on("error", (error) => {
453
+ if (!terminalError && phase !== "complete") {
454
+ terminalError = error;
455
+ }
456
+ });
457
+ child.on("close", (status) => {
458
+ clearTimeout(timeout);
459
+ if (settled) {
460
+ return;
461
+ }
462
+ settled = true;
463
+ if (terminalError) {
464
+ reject(terminalError);
465
+ return;
466
+ }
467
+ if (status !== 0) {
468
+ reject(new CodexSqliteSessionError({
469
+ message: stderr.trim() || `sqlite3 exited with status ${status ?? "unknown"}`,
470
+ status,
471
+ stage: phase
472
+ }));
473
+ return;
474
+ }
475
+ if (!result || phase !== "complete") {
476
+ reject(new CodexSqliteSessionError({
477
+ message: "sqlite3 exited before the thread query protocol completed",
478
+ status,
479
+ stage: phase
480
+ }));
481
+ return;
482
+ }
483
+ resolve(result);
484
+ });
485
+ child.stdin.write(`BEGIN;\npragma table_info(threads);\n` +
486
+ `select '${schemaControl}' as "${controlColumn}";\n`);
487
+ });
488
+ }
489
+ function validateCodexThreadQueryResult(result) {
490
+ validateCodexThreadColumns(result.columns);
491
+ return result.rows;
492
+ }
493
+ function validateCodexThreadColumns(columns) {
494
+ if (!columns.includes("id") || !columns.includes("cwd")) {
495
+ throw new Error("Codex threads table is missing required id or cwd columns");
496
+ }
497
+ }
498
+ function sqliteReadWriteUri(dbPath) {
499
+ const uri = pathToFileURL(path.resolve(dbPath));
500
+ uri.searchParams.set("mode", "rw");
501
+ return uri.href;
502
+ }
503
+ function waitForMilliseconds(milliseconds) {
504
+ return new Promise((resolve) => setTimeout(resolve, Math.max(0, milliseconds)));
505
+ }
506
+ function inspectCodexSqliteFiles(dbPath) {
507
+ return {
508
+ dbPath: path.resolve(dbPath),
509
+ main: inspectCodexSqliteFile(dbPath),
510
+ wal: inspectCodexSqliteFile(`${dbPath}-wal`),
511
+ shm: inspectCodexSqliteFile(`${dbPath}-shm`)
512
+ };
513
+ }
514
+ function inspectCodexSqliteFile(filePath) {
515
+ try {
516
+ const stat = fs.statSync(filePath);
517
+ return {
518
+ path: path.resolve(filePath),
519
+ exists: true,
520
+ kind: stat.isFile()
521
+ ? "file"
522
+ : stat.isDirectory()
523
+ ? "directory"
524
+ : "other",
525
+ device: String(stat.dev),
526
+ inode: String(stat.ino),
527
+ size: stat.size,
528
+ mtimeMs: stat.mtimeMs
529
+ };
530
+ }
531
+ catch (error) {
532
+ const code = typeof error === "object" && error !== null && "code" in error
533
+ ? String(error.code)
534
+ : undefined;
535
+ return {
536
+ path: path.resolve(filePath),
537
+ exists: false,
538
+ ...(code ? { errorCode: code } : {})
539
+ };
540
+ }
541
+ }
542
+ function assertStableCodexSqliteMain({ baseline, current, stage, selectedPath = current.dbPath }) {
543
+ const samePath = Boolean(selectedPath &&
544
+ path.resolve(selectedPath) === baseline.dbPath &&
545
+ current.dbPath === baseline.dbPath);
546
+ const sameFile = Boolean(baseline.main.exists &&
547
+ baseline.main.kind === "file" &&
548
+ current.main.exists &&
549
+ current.main.kind === "file" &&
550
+ baseline.main.device === current.main.device &&
551
+ baseline.main.inode === current.main.inode);
552
+ if (samePath && sameFile) {
553
+ return;
554
+ }
555
+ throw new Error(`Codex SQLite main database changed during ${stage}; refusing stale ` +
556
+ `thread discovery (selected_db=${selectedPath ?? "missing"}, ` +
557
+ `baseline_db=${baseline.dbPath}, current_db=${current.dbPath}; ` +
558
+ `${formatCodexSqliteFiles(current)})`);
559
+ }
560
+ function codexSqliteQueryFailure(error, context) {
561
+ const errorRecord = typeof error === "object" && error !== null
562
+ ? error
563
+ : undefined;
564
+ const reportedStatus = typeof errorRecord?.status === "number"
565
+ ? errorRecord.status
566
+ : null;
567
+ const reportedStage = typeof errorRecord?.stage === "string"
568
+ ? errorRecord.stage
569
+ : undefined;
570
+ return {
571
+ dbPath: path.resolve(context.dbPath),
572
+ stage: reportedStage
573
+ ? `${context.stage}:${reportedStage}`
574
+ : context.stage,
575
+ status: reportedStatus !== null && Number.isInteger(reportedStatus)
576
+ ? reportedStatus
577
+ : null,
578
+ detail: error instanceof Error ? error.message : String(error),
579
+ files: context.files,
580
+ previousFailure: context.previousFailure
581
+ };
582
+ }
583
+ function isSqliteCantOpen(failure) {
584
+ return failure.status === 14 ||
585
+ /(?:SQLITE_CANTOPEN|unable to open database file|\(14\))/iu.test(failure.detail);
586
+ }
587
+ function codexSqliteQueryDiagnosticError(failure) {
588
+ const prior = failure.previousFailure
589
+ ? `; previous=[stage=${failure.previousFailure.stage},status=` +
590
+ `${failure.previousFailure.status ?? "unknown"},` +
591
+ `${formatCodexSqliteFiles(failure.previousFailure.files)}]`
592
+ : "";
593
+ return new Error(`Codex SQLite thread query failed ` +
594
+ `(stage=${failure.stage}, db=${failure.dbPath}, ` +
595
+ `status=${failure.status ?? "unknown"}${prior}; ` +
596
+ `${formatCodexSqliteFiles(failure.files)}): ` +
597
+ failure.detail.replace(/\s+/gu, " ").trim());
598
+ }
599
+ function formatCodexSqliteFiles(snapshot) {
600
+ return [
601
+ ["main", snapshot.main],
602
+ ["wal", snapshot.wal],
603
+ ["shm", snapshot.shm]
604
+ ].map(([label, value]) => {
605
+ const file = value;
606
+ if (!file.exists) {
607
+ return `${label}=missing${file.errorCode ? `(${file.errorCode})` : ""}`;
608
+ }
609
+ return `${label}=${file.kind}(dev=${file.device},ino=${file.inode},` +
610
+ `size=${file.size},mtime_ms=${Math.trunc(file.mtimeMs ?? 0)})`;
611
+ }).join(" ");
612
+ }
174
613
  export function latestStateDbPath(codexHome) {
175
614
  if (!fs.existsSync(codexHome)) {
176
615
  return undefined;
@@ -178,7 +617,18 @@ export function latestStateDbPath(codexHome) {
178
617
  return fs.readdirSync(codexHome)
179
618
  .filter((entry) => /^state_\d+\.sqlite$/u.test(entry))
180
619
  .map((entry) => path.join(codexHome, entry))
181
- .sort((left, right) => fs.statSync(right).mtimeMs - fs.statSync(left).mtimeMs)[0];
620
+ .flatMap((filePath) => {
621
+ try {
622
+ const stat = fs.statSync(filePath);
623
+ return stat.isFile() ? [{ filePath, mtimeMs: stat.mtimeMs }] : [];
624
+ }
625
+ catch {
626
+ // Codex may rotate a versioned state database while discovery is
627
+ // enumerating it. The caller will re-resolve and validate identity.
628
+ return [];
629
+ }
630
+ })
631
+ .sort((left, right) => right.mtimeMs - left.mtimeMs)[0]?.filePath;
182
632
  }
183
633
  export function parseLsofOpenFiles(text) {
184
634
  const records = [];
@@ -420,7 +870,7 @@ function readCodexSessionMetadata(filePath, expectedDevice, expectedInode, pid)
420
870
  fs.closeSync(fd);
421
871
  }
422
872
  }
423
- export function buildThreadSelect(columns, limit) {
873
+ export function buildThreadSelect(columns, limit, filters = {}) {
424
874
  const columnSet = new Set(columns);
425
875
  const updatedAtExpression = columnSet.has("updated_at_ms")
426
876
  ? "updated_at_ms"
@@ -441,7 +891,54 @@ export function buildThreadSelect(columns, limit) {
441
891
  columnSet.has("cli_version") ? "cli_version" : "null as cli_version",
442
892
  columnSet.has("name") ? "name" : "null as name"
443
893
  ].join(", ");
444
- return `select ${select} from threads order by ${updatedAtExpression} desc limit ${Math.max(1, Math.floor(limit))}`;
894
+ const predicates = [];
895
+ if (filters.cwd !== undefined) {
896
+ predicates.push("cwd collate binary = :akk_cwd");
897
+ }
898
+ if (filters.source !== undefined) {
899
+ predicates.push(columnSet.has("source")
900
+ ? "source collate binary = :akk_source"
901
+ : "0 = 1");
902
+ }
903
+ if (filters.archived !== undefined) {
904
+ if (columnSet.has("archived")) {
905
+ predicates.push(`archived = ${filters.archived ? "1" : "0"}`);
906
+ }
907
+ else if (filters.archived) {
908
+ predicates.push("0 = 1");
909
+ }
910
+ }
911
+ if (filters.modelProvider !== undefined) {
912
+ predicates.push(columnSet.has("model_provider")
913
+ ? "model_provider collate binary = :akk_model_provider"
914
+ : "0 = 1");
915
+ }
916
+ const where = predicates.length > 0
917
+ ? ` where ${predicates.join(" and ")}`
918
+ : "";
919
+ const deterministicTieBreak = predicates.length > 0 ? ", id desc" : "";
920
+ return `select ${select} from threads${where} order by ${updatedAtExpression} desc${deterministicTieBreak} limit ${Math.max(1, Math.floor(limit))}`;
921
+ }
922
+ function sqliteThreadFilterParameterCommands(filters) {
923
+ if (!filters) {
924
+ return [];
925
+ }
926
+ const values = [
927
+ ["akk_cwd", filters.cwd],
928
+ ["akk_source", filters.source],
929
+ ["akk_model_provider", filters.modelProvider]
930
+ ];
931
+ const present = values.filter((entry) => entry[1] !== undefined);
932
+ if (present.length === 0) {
933
+ return [];
934
+ }
935
+ return [
936
+ ".parameter init",
937
+ ...present.map(([name, value]) => {
938
+ const hex = Buffer.from(value, "utf8").toString("hex");
939
+ return `.parameter set :${name} "CAST(X'${hex}' AS TEXT)"`;
940
+ })
941
+ ];
445
942
  }
446
943
  export function buildThreadByIdSelect(columns, nativeThreadId) {
447
944
  if (!NATIVE_THREAD_ID_PATTERN.test(nativeThreadId)) {
@@ -473,9 +970,10 @@ function codexLifecycleCandidateFromRow({ row, codexHome, request }) {
473
970
  !path.isAbsolute(rowCwd) ||
474
971
  !path.isAbsolute(rolloutPath) ||
475
972
  rowSource !== "cli" ||
476
- rowVersion !== request.agentVersion ||
477
- row.archived === true ||
478
- row.archived === 1 ||
973
+ !rowVersion ||
974
+ !(row.archived === undefined ||
975
+ row.archived === false ||
976
+ row.archived === 0) ||
479
977
  path.resolve(rowCwd) !== path.resolve(request.cwd) ||
480
978
  (request.modelProvider !== undefined &&
481
979
  rowModelProvider !== request.modelProvider)) {
@@ -491,7 +989,7 @@ function codexLifecycleCandidateFromRow({ row, codexHome, request }) {
491
989
  path.resolve(opened.metadata.cwd) !== path.resolve(request.cwd) ||
492
990
  opened.metadata.originator !== "codex-tui" ||
493
991
  opened.metadata.source !== "cli" ||
494
- opened.metadata.cliVersion !== request.agentVersion ||
992
+ opened.metadata.cliVersion !== rowVersion ||
495
993
  (rowModelProvider !== undefined &&
496
994
  opened.metadata.modelProvider !== rowModelProvider) ||
497
995
  (request.modelProvider !== undefined &&
@@ -513,9 +1011,7 @@ function codexLifecycleCandidateFromRow({ row, codexHome, request }) {
513
1011
  rolloutPath: opened.fileToken.path
514
1012
  }))
515
1013
  .digest("hex");
516
- const candidateToken = {
517
- schema: "agent-knock-knock/thread-candidate-token",
518
- version: 1,
1014
+ const tokenFields = {
519
1015
  agent: "codex",
520
1016
  nativeThreadId,
521
1017
  cwd: path.resolve(request.cwd),
@@ -525,6 +1021,18 @@ function codexLifecycleCandidateFromRow({ row, codexHome, request }) {
525
1021
  metadataFingerprint,
526
1022
  modelProvider: opened.metadata.modelProvider
527
1023
  };
1024
+ const candidateToken = opened.metadata.cliVersion === request.agentVersion
1025
+ ? {
1026
+ schema: "agent-knock-knock/thread-candidate-token",
1027
+ version: 1,
1028
+ ...tokenFields
1029
+ }
1030
+ : {
1031
+ schema: "agent-knock-knock/thread-candidate-token",
1032
+ version: 2,
1033
+ ...tokenFields,
1034
+ sourceAgentVersion: opened.metadata.cliVersion
1035
+ };
528
1036
  return {
529
1037
  agent: "codex",
530
1038
  nativeThreadId,
@@ -533,6 +1041,7 @@ function codexLifecycleCandidateFromRow({ row, codexHome, request }) {
533
1041
  rootInteractive: true,
534
1042
  fileToken: opened.fileToken,
535
1043
  agentVersion: request.agentVersion,
1044
+ sourceAgentVersion: opened.metadata.cliVersion,
536
1045
  title,
537
1046
  preview,
538
1047
  updatedAtMs,
@@ -541,6 +1050,9 @@ function codexLifecycleCandidateFromRow({ row, codexHome, request }) {
541
1050
  candidateToken
542
1051
  };
543
1052
  }
1053
+ function candidateSourceAgentVersion(token) {
1054
+ return token.version === 2 ? token.sourceAgentVersion : token.agentVersion;
1055
+ }
544
1056
  function readCodexLifecycleMetadata({ codexHome, rolloutPath, nativeThreadId }) {
545
1057
  const configuredRoot = path.resolve(codexHome, "sessions");
546
1058
  const lexicalRelative = path.relative(configuredRoot, path.resolve(rolloutPath));