@kynetic-ai/spec 0.7.0 → 0.9.0

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.
Files changed (86) hide show
  1. package/dist/agents/adapters.d.ts +2 -0
  2. package/dist/agents/adapters.d.ts.map +1 -1
  3. package/dist/agents/adapters.js +18 -0
  4. package/dist/agents/adapters.js.map +1 -1
  5. package/dist/agents/spawner.d.ts +2 -0
  6. package/dist/agents/spawner.d.ts.map +1 -1
  7. package/dist/agents/spawner.js +4 -2
  8. package/dist/agents/spawner.js.map +1 -1
  9. package/dist/cli/commands/ralph.d.ts +48 -0
  10. package/dist/cli/commands/ralph.d.ts.map +1 -1
  11. package/dist/cli/commands/ralph.js +344 -86
  12. package/dist/cli/commands/ralph.js.map +1 -1
  13. package/dist/cli/commands/session/commands.d.ts.map +1 -1
  14. package/dist/cli/commands/session/commands.js +8 -0
  15. package/dist/cli/commands/session/commands.js.map +1 -1
  16. package/dist/cli/commands/session/compact.d.ts +13 -0
  17. package/dist/cli/commands/session/compact.d.ts.map +1 -0
  18. package/dist/cli/commands/session/compact.js +207 -0
  19. package/dist/cli/commands/session/compact.js.map +1 -0
  20. package/dist/cli/commands/session/log.d.ts +2 -0
  21. package/dist/cli/commands/session/log.d.ts.map +1 -1
  22. package/dist/cli/commands/session/log.js +12 -2
  23. package/dist/cli/commands/session/log.js.map +1 -1
  24. package/dist/cli/commands/setup-seeding.d.ts +6 -3
  25. package/dist/cli/commands/setup-seeding.d.ts.map +1 -1
  26. package/dist/cli/commands/setup-seeding.js +20 -4
  27. package/dist/cli/commands/setup-seeding.js.map +1 -1
  28. package/dist/cli/commands/setup.d.ts +3 -2
  29. package/dist/cli/commands/setup.d.ts.map +1 -1
  30. package/dist/cli/commands/setup.js +10 -90
  31. package/dist/cli/commands/setup.js.map +1 -1
  32. package/dist/cli/commands/skill-install.d.ts.map +1 -1
  33. package/dist/cli/commands/skill-install.js +104 -1
  34. package/dist/cli/commands/skill-install.js.map +1 -1
  35. package/dist/lib/codex-config.d.ts +14 -0
  36. package/dist/lib/codex-config.d.ts.map +1 -0
  37. package/dist/lib/codex-config.js +88 -0
  38. package/dist/lib/codex-config.js.map +1 -0
  39. package/dist/parser/agent-detection.d.ts +14 -0
  40. package/dist/parser/agent-detection.d.ts.map +1 -0
  41. package/dist/parser/agent-detection.js +118 -0
  42. package/dist/parser/agent-detection.js.map +1 -0
  43. package/dist/parser/setup-status.d.ts +4 -3
  44. package/dist/parser/setup-status.d.ts.map +1 -1
  45. package/dist/parser/setup-status.js +4 -10
  46. package/dist/parser/setup-status.js.map +1 -1
  47. package/dist/parser/shadow.d.ts.map +1 -1
  48. package/dist/parser/shadow.js +22 -31
  49. package/dist/parser/shadow.js.map +1 -1
  50. package/dist/parser/skill-render.d.ts +23 -1
  51. package/dist/parser/skill-render.d.ts.map +1 -1
  52. package/dist/parser/skill-render.js +126 -17
  53. package/dist/parser/skill-render.js.map +1 -1
  54. package/dist/ralph/subagent.d.ts +2 -0
  55. package/dist/ralph/subagent.d.ts.map +1 -1
  56. package/dist/ralph/subagent.js +2 -0
  57. package/dist/ralph/subagent.js.map +1 -1
  58. package/dist/ralph/wrap-up.d.ts +2 -0
  59. package/dist/ralph/wrap-up.d.ts.map +1 -1
  60. package/dist/ralph/wrap-up.js +1 -0
  61. package/dist/ralph/wrap-up.js.map +1 -1
  62. package/dist/sessions/store.d.ts +67 -0
  63. package/dist/sessions/store.d.ts.map +1 -1
  64. package/dist/sessions/store.js +396 -16
  65. package/dist/sessions/store.js.map +1 -1
  66. package/package.json +2 -1
  67. package/plugin/.claude-plugin/marketplace.json +1 -1
  68. package/plugin/.claude-plugin/plugin.json +1 -1
  69. package/plugin/plugins/kspec/skills/create-workflow/SKILL.md +10 -0
  70. package/plugin/plugins/kspec/skills/plan/SKILL.md +10 -0
  71. package/plugin/plugins/kspec/skills/review/SKILL.md +2 -0
  72. package/plugin/plugins/kspec/skills/task-work/SKILL.md +10 -0
  73. package/plugin/plugins/kspec/skills/triage-automation/SKILL.md +1 -0
  74. package/templates/skills/create-workflow/SKILL.md +12 -2
  75. package/templates/skills/manifest.yaml +11 -0
  76. package/templates/skills/observations/SKILL.md +2 -2
  77. package/templates/skills/plan/SKILL.md +15 -5
  78. package/templates/skills/reflect/SKILL.md +1 -1
  79. package/templates/skills/review/SKILL.md +4 -2
  80. package/templates/skills/task-work/SKILL.md +16 -6
  81. package/templates/skills/triage/SKILL.md +1 -1
  82. package/templates/skills/triage/docs/inbox.md +1 -1
  83. package/templates/skills/triage/docs/observations.md +1 -1
  84. package/templates/skills/triage-automation/SKILL.md +2 -1
  85. package/templates/skills/triage-inbox/SKILL.md +3 -3
  86. package/templates/skills/writing-specs/SKILL.md +6 -6
@@ -14,6 +14,8 @@
14
14
  import * as fs from "node:fs";
15
15
  import * as fsPromises from "node:fs/promises";
16
16
  import * as path from "node:path";
17
+ import { createHash, randomUUID } from "node:crypto";
18
+ import { parse as parseTOML, stringify as stringifyTOML } from "smol-toml";
17
19
  import * as YAML from "yaml";
18
20
  import { SessionEventSchema, SessionMetadataSchema, TaskBudgetSchema, } from "./types.js";
19
21
  // ─── Constants ───────────────────────────────────────────────────────────────
@@ -21,6 +23,12 @@ const SESSIONS_DIR = "sessions";
21
23
  const METADATA_FILE = "session.yaml";
22
24
  const EVENTS_FILE = "events.jsonl";
23
25
  const BUDGET_FILE = "budget.json";
26
+ const BLOBS_DIR = "blobs";
27
+ // Event persistence guardrails: keep single-line events bounded in size while
28
+ // preserving full payloads via externalized blob files.
29
+ const EVENT_LINE_MAX_BYTES = 256 * 1024;
30
+ const EVENT_FIELD_EXTERNALIZE_BYTES = 16 * 1024;
31
+ const EVENT_PREVIEW_MAX_BYTES = 512;
24
32
  // ─── Path Helpers ────────────────────────────────────────────────────────────
25
33
  /**
26
34
  * Get the sessions directory path within a spec directory.
@@ -59,6 +67,12 @@ export function getSessionContextPath(specDir, sessionId, iteration) {
59
67
  export function getSessionBudgetPath(specDir, sessionId) {
60
68
  return path.join(getSessionDir(specDir, sessionId), BUDGET_FILE);
61
69
  }
70
+ /**
71
+ * Get the path to a session's blob directory.
72
+ */
73
+ export function getSessionBlobDir(specDir, sessionId) {
74
+ return path.join(getSessionDir(specDir, sessionId), BLOBS_DIR);
75
+ }
62
76
  // ─── Session CRUD ────────────────────────────────────────────────────────────
63
77
  /**
64
78
  * Create a new session with metadata.
@@ -264,7 +278,171 @@ export async function closeSession(specDir, sessionId, status, reason) {
264
278
  await fsPromises.writeFile(metadataPath, content, "utf-8");
265
279
  return updated;
266
280
  }
267
- // ─── Event Storage ───────────────────────────────────────────────────────────
281
+ function isRecord(value) {
282
+ return typeof value === "object" && value !== null && !Array.isArray(value);
283
+ }
284
+ export function isSessionBlobPointer(value) {
285
+ if (!isRecord(value))
286
+ return false;
287
+ return (typeof value.path === "string" &&
288
+ typeof value.bytes === "number" &&
289
+ typeof value.sha256 === "string" &&
290
+ value.truncated === true &&
291
+ typeof value.preview === "string");
292
+ }
293
+ function stringifyPayload(value) {
294
+ if (typeof value === "string")
295
+ return value;
296
+ try {
297
+ return JSON.stringify(value);
298
+ }
299
+ catch {
300
+ return String(value);
301
+ }
302
+ }
303
+ function payloadBytes(value) {
304
+ return Buffer.byteLength(stringifyPayload(value), "utf-8");
305
+ }
306
+ function toPreview(content) {
307
+ const totalBytes = Buffer.byteLength(content, "utf-8");
308
+ if (totalBytes <= EVENT_PREVIEW_MAX_BYTES) {
309
+ return content;
310
+ }
311
+ let preview = "";
312
+ let usedBytes = 0;
313
+ for (const char of content) {
314
+ const charBytes = Buffer.byteLength(char, "utf-8");
315
+ if (usedBytes + charBytes > EVENT_PREVIEW_MAX_BYTES)
316
+ break;
317
+ preview += char;
318
+ usedBytes += charBytes;
319
+ }
320
+ return `${preview}...`;
321
+ }
322
+ function normalizeFieldLabel(pathSegments) {
323
+ const joined = pathSegments.length > 0 ? pathSegments.join("-") : "event-data";
324
+ const cleaned = joined
325
+ .replace(/[^a-zA-Z0-9._-]+/g, "-")
326
+ .replace(/-+/g, "-")
327
+ .replace(/^-|-$/g, "")
328
+ .slice(0, 48);
329
+ return cleaned || "event-data";
330
+ }
331
+ function shouldExternalizeField(pathSegments, value) {
332
+ if (value === null || value === undefined)
333
+ return false;
334
+ const keyPath = pathSegments.join(".");
335
+ if (keyPath === "update.rawOutput") {
336
+ return payloadBytes(value) > EVENT_FIELD_EXTERNALIZE_BYTES;
337
+ }
338
+ if (keyPath === "update._meta.claudeCode.toolResponse.stdout" ||
339
+ keyPath === "update._meta.claudeCode.toolResponse.stderr") {
340
+ return payloadBytes(value) > EVENT_FIELD_EXTERNALIZE_BYTES;
341
+ }
342
+ if (pathSegments[pathSegments.length - 1] === "text") {
343
+ const hasChunkContext = pathSegments.includes("content") ||
344
+ pathSegments.includes("chunk") ||
345
+ pathSegments.includes("delta");
346
+ if (hasChunkContext) {
347
+ return payloadBytes(value) > EVENT_FIELD_EXTERNALIZE_BYTES;
348
+ }
349
+ }
350
+ return false;
351
+ }
352
+ async function createBlobPointer(specDir, sessionId, seq, pathSegments, value, context) {
353
+ const content = stringifyPayload(value);
354
+ const bytes = Buffer.byteLength(content, "utf-8");
355
+ const sha256 = createHash("sha256").update(content).digest("hex");
356
+ const fieldLabel = normalizeFieldLabel(pathSegments);
357
+ const dryRunCounter = context.dryRunCounter ?? 0;
358
+ const fileName = context.dryRun
359
+ ? `${String(seq).padStart(6, "0")}-${fieldLabel}-dry-run-${String(dryRunCounter).padStart(4, "0")}.blob`
360
+ : `${String(seq).padStart(6, "0")}-${fieldLabel}-${randomUUID()}.blob`;
361
+ const relativePath = path.posix.join(BLOBS_DIR, fileName);
362
+ if (!context.dryRun) {
363
+ if (!context.ensuredDir) {
364
+ await fsPromises.mkdir(context.blobDir, { recursive: true });
365
+ context.ensuredDir = true;
366
+ }
367
+ const absolutePath = path.join(getSessionDir(specDir, sessionId), relativePath);
368
+ await fsPromises.writeFile(absolutePath, content, "utf-8");
369
+ }
370
+ context.createdBlobs = (context.createdBlobs ?? 0) + 1;
371
+ context.dryRunCounter = dryRunCounter + 1;
372
+ return {
373
+ path: relativePath,
374
+ bytes,
375
+ sha256,
376
+ truncated: true,
377
+ preview: toPreview(content),
378
+ };
379
+ }
380
+ async function externalizeOversizedPayloads(specDir, sessionId, seq, value, pathSegments, context) {
381
+ if (isSessionBlobPointer(value)) {
382
+ return value;
383
+ }
384
+ if (shouldExternalizeField(pathSegments, value)) {
385
+ return createBlobPointer(specDir, sessionId, seq, pathSegments, value, context);
386
+ }
387
+ if (Array.isArray(value)) {
388
+ return Promise.all(value.map((entry, idx) => externalizeOversizedPayloads(specDir, sessionId, seq, entry, [
389
+ ...pathSegments,
390
+ String(idx),
391
+ ], context)));
392
+ }
393
+ if (isRecord(value)) {
394
+ const next = {};
395
+ for (const [key, child] of Object.entries(value)) {
396
+ next[key] = await externalizeOversizedPayloads(specDir, sessionId, seq, child, [...pathSegments, key], context);
397
+ }
398
+ return next;
399
+ }
400
+ return value;
401
+ }
402
+ function resolveBlobAbsolutePath(specDir, sessionId, relativePath) {
403
+ const sessionDir = path.resolve(getSessionDir(specDir, sessionId));
404
+ const absolutePath = path.resolve(sessionDir, relativePath);
405
+ if (absolutePath === sessionDir ||
406
+ absolutePath.startsWith(`${sessionDir}${path.sep}`)) {
407
+ return absolutePath;
408
+ }
409
+ return null;
410
+ }
411
+ async function resolveBlobPointer(specDir, sessionId, pointer) {
412
+ const absolutePath = resolveBlobAbsolutePath(specDir, sessionId, pointer.path);
413
+ if (!absolutePath) {
414
+ return { ...pointer, content: pointer.preview };
415
+ }
416
+ try {
417
+ const content = await fsPromises.readFile(absolutePath, "utf-8");
418
+ return { ...pointer, content };
419
+ }
420
+ catch {
421
+ return { ...pointer, content: pointer.preview };
422
+ }
423
+ }
424
+ /**
425
+ * Resolve all blob pointers in a value tree to include full payload content.
426
+ *
427
+ * Default flows keep compact pointer objects (preview-only). This helper powers
428
+ * explicit on-demand blob resolution in session log commands.
429
+ */
430
+ export async function resolveSessionBlobPointers(specDir, sessionId, value) {
431
+ if (isSessionBlobPointer(value)) {
432
+ return resolveBlobPointer(specDir, sessionId, value);
433
+ }
434
+ if (Array.isArray(value)) {
435
+ return Promise.all(value.map((entry) => resolveSessionBlobPointers(specDir, sessionId, entry)));
436
+ }
437
+ if (isRecord(value)) {
438
+ const next = {};
439
+ for (const [key, child] of Object.entries(value)) {
440
+ next[key] = await resolveSessionBlobPointers(specDir, sessionId, child);
441
+ }
442
+ return next;
443
+ }
444
+ return value;
445
+ }
268
446
  /**
269
447
  * Get the current event count for a session (for seq assignment).
270
448
  *
@@ -320,14 +498,159 @@ export async function appendEvent(specDir, input) {
320
498
  trace_id: input.trace_id,
321
499
  data: input.data,
322
500
  };
501
+ // AC: @session-events ac-8, ac-9 - Externalize oversized payload fields
502
+ // before writing to events.jsonl.
503
+ const externalizedData = await externalizeOversizedPayloads(specDir, input.session_id, seq, event.data, [], {
504
+ blobDir: getSessionBlobDir(specDir, input.session_id),
505
+ ensuredDir: false,
506
+ });
507
+ const eventWithGuardrails = {
508
+ ...event,
509
+ data: externalizedData,
510
+ };
323
511
  // Validate event
324
- const validated = SessionEventSchema.parse(event);
512
+ let validated = SessionEventSchema.parse(eventWithGuardrails);
513
+ // Event-line safety cap: if a single JSONL line is still too large after
514
+ // targeted field externalization, externalize the entire data payload.
515
+ let line = JSON.stringify(validated);
516
+ if (Buffer.byteLength(line, "utf-8") > EVENT_LINE_MAX_BYTES) {
517
+ const blobContext = {
518
+ blobDir: getSessionBlobDir(specDir, input.session_id),
519
+ ensuredDir: false,
520
+ };
521
+ const fullDataPointer = await createBlobPointer(specDir, input.session_id, seq, [], validated.data, blobContext);
522
+ validated = SessionEventSchema.parse({
523
+ ...validated,
524
+ data: fullDataPointer,
525
+ });
526
+ line = JSON.stringify(validated);
527
+ }
325
528
  // AC: @session-events ac-3 - Use synchronous append for crash safety
326
529
  // This ensures the line is fully written before returning
327
- const line = `${JSON.stringify(validated)}\n`;
328
- fs.appendFileSync(eventsPath, line, "utf-8");
530
+ fs.appendFileSync(eventsPath, `${line}\n`, "utf-8");
329
531
  return validated;
330
532
  }
533
+ /**
534
+ * Retroactively compact a session event log by externalizing oversized payloads.
535
+ *
536
+ * Reuses the same two-stage externalization pipeline as appendEvent():
537
+ * 1) Field-level externalization for known large payload fields
538
+ * 2) Full-data externalization if a line still exceeds EVENT_LINE_MAX_BYTES
539
+ *
540
+ * Writes are atomic (temp-file then rename). When dryRun is enabled, no files
541
+ * are modified and no blob files are written.
542
+ */
543
+ export async function compactSessionEvents(specDir, sessionId, options = {}) {
544
+ const dryRun = options.dryRun === true;
545
+ const renameFn = options.renameFn ?? fsPromises.rename;
546
+ const eventsPath = getSessionEventsPath(specDir, sessionId);
547
+ let content;
548
+ try {
549
+ content = await fsPromises.readFile(eventsPath, "utf-8");
550
+ }
551
+ catch (err) {
552
+ if (err instanceof Error && "code" in err && err.code === "ENOENT") {
553
+ return {
554
+ events_processed: 0,
555
+ blobs_created: 0,
556
+ bytes_before: 0,
557
+ bytes_after: 0,
558
+ bytes_reclaimed: 0,
559
+ changed: false,
560
+ reason: "missing_events_file",
561
+ dry_run: dryRun,
562
+ };
563
+ }
564
+ throw err;
565
+ }
566
+ const bytesBefore = Buffer.byteLength(content, "utf-8");
567
+ const sourceLines = content
568
+ .split("\n")
569
+ .filter((line) => line.trim().length > 0);
570
+ if (sourceLines.length === 0) {
571
+ return {
572
+ events_processed: 0,
573
+ blobs_created: 0,
574
+ bytes_before: bytesBefore,
575
+ bytes_after: bytesBefore,
576
+ bytes_reclaimed: 0,
577
+ changed: false,
578
+ reason: "empty_events_file",
579
+ dry_run: dryRun,
580
+ };
581
+ }
582
+ const blobContext = {
583
+ blobDir: getSessionBlobDir(specDir, sessionId),
584
+ ensuredDir: false,
585
+ dryRun,
586
+ createdBlobs: 0,
587
+ dryRunCounter: 0,
588
+ };
589
+ const compactedLines = [];
590
+ for (let i = 0; i < sourceLines.length; i += 1) {
591
+ const line = sourceLines[i];
592
+ let parsed;
593
+ try {
594
+ parsed = JSON.parse(line);
595
+ }
596
+ catch (err) {
597
+ throw new Error(`Invalid JSON in events log at line ${i + 1}: ${err instanceof Error ? err.message : String(err)}`);
598
+ }
599
+ const event = SessionEventSchema.parse(parsed);
600
+ const externalizedData = await externalizeOversizedPayloads(specDir, sessionId, event.seq, event.data, [], blobContext);
601
+ let validated = SessionEventSchema.parse({
602
+ ...event,
603
+ data: externalizedData,
604
+ });
605
+ let compactedLine = JSON.stringify(validated);
606
+ if (Buffer.byteLength(compactedLine, "utf-8") > EVENT_LINE_MAX_BYTES) {
607
+ const fullDataPointer = await createBlobPointer(specDir, sessionId, event.seq, [], validated.data, blobContext);
608
+ validated = SessionEventSchema.parse({
609
+ ...validated,
610
+ data: fullDataPointer,
611
+ });
612
+ compactedLine = JSON.stringify(validated);
613
+ }
614
+ compactedLines.push(compactedLine);
615
+ }
616
+ const compactedContent = `${compactedLines.join("\n")}\n`;
617
+ const changed = compactedContent !== content;
618
+ const bytesAfter = changed ? Buffer.byteLength(compactedContent, "utf-8") : bytesBefore;
619
+ if (!changed) {
620
+ return {
621
+ events_processed: sourceLines.length,
622
+ blobs_created: 0,
623
+ bytes_before: bytesBefore,
624
+ bytes_after: bytesBefore,
625
+ bytes_reclaimed: 0,
626
+ changed: false,
627
+ reason: "already_compacted",
628
+ dry_run: dryRun,
629
+ };
630
+ }
631
+ if (!dryRun) {
632
+ const sessionDir = getSessionDir(specDir, sessionId);
633
+ const tmpPath = path.join(sessionDir, `.${EVENTS_FILE}.${process.pid}.${Date.now()}.tmp`);
634
+ try {
635
+ await fsPromises.writeFile(tmpPath, compactedContent, "utf-8");
636
+ await renameFn(tmpPath, eventsPath);
637
+ }
638
+ catch (err) {
639
+ await fsPromises.unlink(tmpPath).catch(() => undefined);
640
+ throw err;
641
+ }
642
+ }
643
+ return {
644
+ events_processed: sourceLines.length,
645
+ blobs_created: blobContext.createdBlobs ?? 0,
646
+ bytes_before: bytesBefore,
647
+ bytes_after: bytesAfter,
648
+ bytes_reclaimed: Math.max(0, bytesBefore - bytesAfter),
649
+ changed: true,
650
+ reason: dryRun ? "would_compact" : "compacted",
651
+ dry_run: dryRun,
652
+ };
653
+ }
331
654
  /**
332
655
  * Read all events from a session.
333
656
  *
@@ -1119,6 +1442,7 @@ export async function searchSessionEvents(specDir, pattern, options = {}) {
1119
1442
  const rawLimit = options.limit ?? 50;
1120
1443
  const limit = Number.isNaN(rawLimit) || rawLimit <= 0 ? 50 : rawLimit;
1121
1444
  const lowerPattern = pattern.toLowerCase();
1445
+ const resolveBlobs = options.resolveBlobs ?? false;
1122
1446
  // Get all session summaries for metadata filtering
1123
1447
  const allSummaries = await getAllSessionLogSummaries(specDir);
1124
1448
  // AC: @session-log-search ac-3 - Pre-filter by --since
@@ -1152,8 +1476,9 @@ export async function searchSessionEvents(specDir, pattern, options = {}) {
1152
1476
  for (const line of lines) {
1153
1477
  if (totalMatches >= limit)
1154
1478
  break;
1155
- // Quick substring pre-filter before parsing JSON
1156
- if (!line.toLowerCase().includes(lowerPattern))
1479
+ // Quick substring pre-filter before parsing JSON.
1480
+ // Disabled when resolving blobs because full content lives outside line.
1481
+ if (!resolveBlobs && !line.toLowerCase().includes(lowerPattern))
1157
1482
  continue;
1158
1483
  try {
1159
1484
  const event = JSON.parse(line);
@@ -1172,8 +1497,12 @@ export async function searchSessionEvents(specDir, pattern, options = {}) {
1172
1497
  }
1173
1498
  }
1174
1499
  }
1175
- // Verify match in stringified data (not just line, in case pattern appears in metadata)
1176
- const dataStr = JSON.stringify(event.data);
1500
+ const searchableData = resolveBlobs
1501
+ ? await resolveSessionBlobPointers(specDir, summary.id, event.data)
1502
+ : event.data;
1503
+ // Verify match in stringified data (not just line, in case pattern
1504
+ // appears in metadata)
1505
+ const dataStr = JSON.stringify(searchableData);
1177
1506
  if (!dataStr.toLowerCase().includes(lowerPattern))
1178
1507
  continue;
1179
1508
  // AC: @session-log-search ac-4 - Create match with excerpt
@@ -1181,7 +1510,7 @@ export async function searchSessionEvents(specDir, pattern, options = {}) {
1181
1510
  session_id: summary.id,
1182
1511
  timestamp: event.ts,
1183
1512
  event_type: event.type,
1184
- content_excerpt: extractContentExcerpt(event.data, pattern, 200),
1513
+ content_excerpt: extractContentExcerpt(searchableData, pattern, 200),
1185
1514
  });
1186
1515
  totalMatches++;
1187
1516
  }
@@ -1411,12 +1740,12 @@ async function removeDotenvSessionId(filePath) {
1411
1740
  */
1412
1741
  export async function injectCodexEnv(sessionId) {
1413
1742
  const configDir = path.join(process.env.HOME || process.env.USERPROFILE || "", ".codex");
1414
- const configPath = path.join(configDir, "config.json");
1743
+ const configPath = path.join(configDir, "config.toml");
1415
1744
  await fsPromises.mkdir(configDir, { recursive: true });
1416
1745
  let config = {};
1417
1746
  try {
1418
1747
  const content = await fsPromises.readFile(configPath, "utf-8");
1419
- config = JSON.parse(content);
1748
+ config = parseTOML(content);
1420
1749
  }
1421
1750
  catch (err) {
1422
1751
  // Only start fresh for ENOENT; throw on parse errors to avoid overwriting
@@ -1424,10 +1753,17 @@ export async function injectCodexEnv(sessionId) {
1424
1753
  // File doesn't exist, start fresh
1425
1754
  }
1426
1755
  else {
1427
- throw new Error(`Cannot inject env: ~/.codex/config.json exists but is not valid JSON. ` +
1756
+ throw new Error(`Cannot inject env: ~/.codex/config.toml exists but is not valid TOML. ` +
1428
1757
  `Fix the file manually or remove it, then retry.`);
1429
1758
  }
1430
1759
  }
1760
+ // Capture previous value before overwriting
1761
+ const previousValue = config.shell_environment_policy &&
1762
+ typeof config.shell_environment_policy === "object" &&
1763
+ config.shell_environment_policy.set &&
1764
+ typeof config.shell_environment_policy.set === "object"
1765
+ ? (config.shell_environment_policy.set.KSPEC_SESSION_ID ?? null)
1766
+ : null;
1431
1767
  // Ensure shell_environment_policy.set exists
1432
1768
  if (!config.shell_environment_policy ||
1433
1769
  typeof config.shell_environment_policy !== "object") {
@@ -1438,14 +1774,56 @@ export async function injectCodexEnv(sessionId) {
1438
1774
  policy.set = {};
1439
1775
  }
1440
1776
  policy.set.KSPEC_SESSION_ID = sessionId;
1441
- await fsPromises.writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
1777
+ await fsPromises.writeFile(configPath, stringifyTOML(config) + "\n", "utf-8");
1442
1778
  return {
1443
1779
  injected: true,
1444
1780
  method: "codex_config",
1445
1781
  description: `Added KSPEC_SESSION_ID to Codex config shell_environment_policy.set`,
1446
1782
  path: configPath,
1783
+ previousValue,
1447
1784
  };
1448
1785
  }
1786
+ /**
1787
+ * Remove or restore KSPEC_SESSION_ID in Codex config.
1788
+ *
1789
+ * Reverses the injection performed by injectCodexEnv().
1790
+ * If previousValue is provided, restores it instead of deleting.
1791
+ * Best-effort: silently ignores missing files or missing keys.
1792
+ *
1793
+ * @param previousValue - Value to restore, or null/undefined to delete
1794
+ */
1795
+ export async function removeCodexEnv(previousValue) {
1796
+ const configDir = path.join(process.env.HOME || process.env.USERPROFILE || "", ".codex");
1797
+ const configPath = path.join(configDir, "config.toml");
1798
+ try {
1799
+ const content = await fsPromises.readFile(configPath, "utf-8");
1800
+ const config = parseTOML(content);
1801
+ const rawPolicy = config.shell_environment_policy;
1802
+ if (rawPolicy && typeof rawPolicy === "object") {
1803
+ const policy = rawPolicy;
1804
+ if (policy.set && typeof policy.set === "object") {
1805
+ if (previousValue) {
1806
+ policy.set.KSPEC_SESSION_ID = previousValue;
1807
+ }
1808
+ else {
1809
+ delete policy.set.KSPEC_SESSION_ID;
1810
+ // Remove set section entirely if empty
1811
+ if (Object.keys(policy.set).length === 0) {
1812
+ delete policy.set;
1813
+ }
1814
+ // Remove shell_environment_policy if empty
1815
+ if (Object.keys(policy).length === 0) {
1816
+ delete config.shell_environment_policy;
1817
+ }
1818
+ }
1819
+ }
1820
+ await fsPromises.writeFile(configPath, stringifyTOML(config) + "\n", "utf-8");
1821
+ }
1822
+ }
1823
+ catch {
1824
+ // Best-effort cleanup — file may not exist or may not be valid TOML
1825
+ }
1826
+ }
1449
1827
  /**
1450
1828
  * Inject KSPEC_SESSION_ID into Gemini CLI environment.
1451
1829
  *
@@ -1509,9 +1887,9 @@ export async function injectEnvForAdapter(adapterId, sessionId) {
1509
1887
  case "claude-agent-acp":
1510
1888
  case "claude-code-acp":
1511
1889
  return injectClaudeCodeEnv(sessionId);
1890
+ case "codex-acp":
1891
+ return injectCodexEnv(sessionId);
1512
1892
  // Future harnesses can be added here:
1513
- // case "codex-acp":
1514
- // return injectCodexEnv(sessionId);
1515
1893
  // case "gemini-acp":
1516
1894
  // return injectGeminiEnv(sessionId);
1517
1895
  default:
@@ -1534,7 +1912,9 @@ export async function removeEnvForAdapter(adapterId, previousValue) {
1534
1912
  case "claude-code-acp":
1535
1913
  await removeClaudeCodeEnv(previousValue);
1536
1914
  break;
1537
- // Future harnesses can be added here
1915
+ case "codex-acp":
1916
+ await removeCodexEnv(previousValue);
1917
+ break;
1538
1918
  }
1539
1919
  }
1540
1920
  // ─── Session Validation ───────────────────────────────────────────────────────