@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.
- package/CHANGELOG.md +14 -0
- package/dist/src/cli.js +5 -1
- package/dist/src/cli.js.map +1 -1
- package/dist/src/codex-store-adapter.d.ts +29 -2
- package/dist/src/codex-store-adapter.js +549 -37
- package/dist/src/codex-store-adapter.js.map +1 -1
- package/dist/src/openclaw-plugin-helpers.js +8 -7
- package/dist/src/openclaw-plugin-helpers.js.map +1 -1
- package/dist/src/openclaw-plugin.js +3 -0
- package/dist/src/openclaw-plugin.js.map +1 -1
- package/dist/src/terminal-agent-adapter.d.ts +21 -2
- package/dist/src/terminal-agent-adapter.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,41 +1,56 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
167
|
-
const
|
|
168
|
-
if (
|
|
169
|
-
throw new Error(
|
|
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
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
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
|
|
477
|
-
row.archived ===
|
|
478
|
-
|
|
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 !==
|
|
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
|
|
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));
|