mirror-mind 0.33.4 → 0.34.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.
@@ -12,6 +12,8 @@
12
12
 
13
13
  import { closeSync, existsSync, mkdirSync, openSync, rmSync } from "node:fs";
14
14
  import { join } from "node:path";
15
+ import { isCommandTurn } from "#conversation/commandTurn.ts";
16
+ import { markCloseTailPending } from "#conversation/deferredCloseTail.ts";
15
17
  import {
16
18
  lastAssistantMessageOf,
17
19
  parseHookPayloadObject,
@@ -20,6 +22,7 @@ import {
20
22
  subagentTypeOf,
21
23
  turnIdOf,
22
24
  } from "#conversation/hookPayload.ts";
25
+ import { sessionEndRunsLlmTail } from "#conversation/runtimePolicy.ts";
23
26
  import { backfillAssistantMessages } from "#conversation/transcriptBackfill.ts";
24
27
  import { type WritableDatabase, withTransaction } from "#db/database.ts";
25
28
  import { mirrorForm } from "#mirror/mirrorForm.ts";
@@ -146,7 +149,12 @@ export function getOrCreateSessionConversation(
146
149
  const boundId =
147
150
  bound && typeof bound.conversation_id === "string" ? bound.conversation_id : null;
148
151
  if (boundId) {
149
- const existing = db.prepare("SELECT id FROM conversations WHERE id = ?").get(boundId);
152
+ // Live: exists and not ended. A closed conversation is not reopened by a
153
+ // prompt (CV21.DS4.US4): a `codex resume` after the idle close starts a new
154
+ // one on the same thread id rather than appending to an extracted one.
155
+ const existing = db
156
+ .prepare("SELECT id FROM conversations WHERE id = ? AND ended_at IS NULL")
157
+ .get(boundId);
150
158
  if (existing) return boundId;
151
159
  }
152
160
 
@@ -318,17 +326,39 @@ function startConversation(
318
326
  * finalization in a `finally`. Duplicating that contract in a second async
319
327
  * close path would let the two drift on exactly the property that must not.
320
328
  */
329
+ export interface CloseTailOptions {
330
+ extract: boolean;
331
+ /**
332
+ * Run the close-time metadata finalization (title, summary, tags). Off, there
333
+ * is no LLM work at all: the conversation is flagged as owing it, immediately
334
+ * before `ended_at` so the window against a still-running async Stop logger's
335
+ * metadata write is microseconds, and the next session start's maintenance
336
+ * finishes it (CV21.DS4.TS2, `deferredCloseTail.ts`). Default on.
337
+ */
338
+ finalize?: boolean;
339
+ /**
340
+ * Runs after the state writes and before any LLM work: where `endSession`
341
+ * deactivates the session and `handleSessionEndHook` backfills the
342
+ * transcript, which the close bounds and extraction should see.
343
+ */
344
+ beforeTail?: () => void;
345
+ }
346
+
321
347
  export async function endConversation(
322
348
  db: WritableDatabase,
323
349
  conversationId: string,
324
- options: { extract: boolean },
350
+ options: CloseTailOptions,
325
351
  deps: LoggerDeps,
326
352
  hooks: CloseHooks = {},
327
353
  ): Promise<void> {
354
+ const finalize = options.finalize ?? true;
355
+ if (!finalize) markCloseTailPending(db, conversationId);
328
356
  db.prepare("UPDATE conversations SET ended_at = ? WHERE id = ?").run(
329
357
  deps.nowIso(),
330
358
  conversationId,
331
359
  );
360
+ options.beforeTail?.();
361
+ if (!finalize) return;
332
362
  try {
333
363
  if (options.extract) await hooks.runExtraction?.(db, conversationId);
334
364
  } finally {
@@ -383,19 +413,43 @@ export async function switchConversation(
383
413
  return newConversationId;
384
414
  }
385
415
 
386
- /** Python `end_session`: close the bound conversation and deactivate. */
416
+ /**
417
+ * Python `end_session`: close the bound conversation and deactivate.
418
+ *
419
+ * The session is deactivated BEFORE the tail, not after (CV21.DS4.TS2). A tail
420
+ * cut by the runtime's bound (Codex 1 s, Grok 1.5 s, Gemini CLI 30 s) or a
421
+ * background hook that dies used to leave the session `active = 1`: the row
422
+ * `guessRuntimeSessionId` reads as "the session", which is how another
423
+ * runtime's agent wrote into a Codex conversation (DS12.TS2 L9).
424
+ */
387
425
  export async function endSession(
388
426
  db: WritableDatabase,
389
427
  sessionId: string,
390
- options: { extract: boolean },
428
+ options: CloseTailOptions,
391
429
  deps: LoggerDeps,
392
430
  hooks: CloseHooks = {},
393
- ): Promise<void> {
431
+ ): Promise<boolean> {
394
432
  const runtimeSession = getRuntimeSession(db, sessionId);
395
433
  const conversationId = runtimeSession?.conversationId ?? null;
396
- if (!conversationId) return;
397
- await endConversation(db, conversationId, { extract: options.extract }, deps, hooks);
398
- upsertRuntimeSession(db, sessionId, { active: false, closedAt: deps.nowIso() }, deps.nowIso());
434
+ if (!conversationId) return false;
435
+ // A closed conversation is not closed twice (CV21.DS4.US4): Codex 0.163.0 fires
436
+ // SessionEnd when its daemon reaps the detached thread, and maintenance may have
437
+ // closed the idle conversation first. The session is deactivated; ended_at and
438
+ // the tail are left as they are.
439
+ const alreadyEnded = db
440
+ .prepare("SELECT 1 FROM conversations WHERE id = ? AND ended_at IS NOT NULL")
441
+ .get(conversationId);
442
+ if (alreadyEnded) {
443
+ upsertRuntimeSession(db, sessionId, { active: false, closedAt: deps.nowIso() }, deps.nowIso());
444
+ options.beforeTail?.();
445
+ return true;
446
+ }
447
+ const deactivate = () => {
448
+ upsertRuntimeSession(db, sessionId, { active: false, closedAt: deps.nowIso() }, deps.nowIso());
449
+ options.beforeTail?.();
450
+ };
451
+ await endConversation(db, conversationId, { ...options, beforeTail: deactivate }, deps, hooks);
452
+ return true;
399
453
  }
400
454
 
401
455
  /**
@@ -479,7 +533,7 @@ export type UserPromptHookOutcome =
479
533
  | "malformed_payload"
480
534
  | "missing_session"
481
535
  | "missing_prompt"
482
- | "slash_command"
536
+ | "command_turn"
483
537
  | "bare_mirror_form";
484
538
  }
485
539
  | { action: "logged"; sessionId: string }
@@ -564,7 +618,8 @@ function recordSkippedTurn(
564
618
  * Grok session whose first turn was a Mirror turn used to be labelled
565
619
  * `claude_code` and bound to someone else's session (DS12.TS4 L7, TS2 L9).
566
620
  * And a Mirror turn is a conversation turn: its question is logged without
567
- * the form, where every other slash command stays a command.
621
+ * the form, where every other skill invocation, in `/` or in Codex's `$`
622
+ * (CV21.DS4.TS3), stays a command.
568
623
  */
569
624
  export function handleUserPromptHook(
570
625
  db: WritableDatabase,
@@ -594,11 +649,12 @@ export function handleUserPromptHook(
594
649
  return { action: "skipped", reason: "bare_mirror_form" };
595
650
  }
596
651
  content = mirror.query;
597
- } else if (prompt.startsWith("/")) {
598
- // Slash commands never enter conversation history, and neither does
599
- // their reply: the turn is recorded so the Stop hook knows why.
652
+ } else if (isCommandTurn(prompt)) {
653
+ // A skill invocation, in `/` or in Codex's `$` (CV21.DS4.TS3), never
654
+ // enters conversation history, and neither does its reply: the turn is
655
+ // recorded so the Stop hook knows why.
600
656
  recordSkippedTurn(db, sessionId, turnIdOf(payload), deps);
601
- return { action: "skipped", reason: "slash_command" };
657
+ return { action: "skipped", reason: "command_turn" };
602
658
  }
603
659
 
604
660
  logUserMessage(
@@ -785,7 +841,13 @@ export function handleStopHook(
785
841
  export async function handleSessionEndHook(
786
842
  db: WritableDatabase,
787
843
  rawPayload: string,
788
- options: { mirrorHome: string; claudeProjectDir: string | null; homeDir: string },
844
+ options: {
845
+ mirrorHome: string;
846
+ claudeProjectDir: string | null;
847
+ homeDir: string;
848
+ /** The runtime the hook detected: the fallback when the session row has no label. */
849
+ interface?: string;
850
+ },
789
851
  deps: LoggerDeps,
790
852
  hooks: CloseHooks = {},
791
853
  ): Promise<SessionEndHookOutcome> {
@@ -794,19 +856,41 @@ export async function handleSessionEndHook(
794
856
  if (!payload) return { action: "skipped", reason: "malformed_payload" };
795
857
 
796
858
  const sessionId = payloadString(payload, "session_id");
797
- if (sessionId) await endSession(db, sessionId, { extract: true }, deps, hooks);
798
-
799
859
  const transcriptPath = resolveTranscriptPath(
800
860
  payloadString(payload, "transcript_path"),
801
861
  sessionId,
802
862
  options.claudeProjectDir,
803
863
  options.homeDir,
804
864
  );
805
- const backfilled = transcriptPath !== null && existsSync(transcriptPath);
806
- if (backfilled) backfillAssistantMessages(db, transcriptPath, deps);
865
+ let backfilled = false;
866
+ // After the close, which bounds the backfill window, and before the tail,
867
+ // which should see what the backfill adds (CV21.DS4.TS2).
868
+ const backfill = () => {
869
+ backfilled = transcriptPath !== null && existsSync(transcriptPath);
870
+ if (backfilled && transcriptPath !== null)
871
+ backfillAssistantMessages(db, transcriptPath, deps);
872
+ };
873
+
874
+ if (sessionId) {
875
+ // The label US8 stamps on every prompt; the hook's own detection only when
876
+ // the row carries none; today's behavior when neither names a runtime.
877
+ const label = getRuntimeSession(db, sessionId)?.interface || options.interface || null;
878
+ const finalize = sessionEndRunsLlmTail(label);
879
+ const closed = await endSession(
880
+ db,
881
+ sessionId,
882
+ { extract: finalize, finalize, beforeTail: backfill },
883
+ deps,
884
+ hooks,
885
+ );
886
+ // A session with no bound conversation closes nothing; the backfill still runs.
887
+ if (!closed) backfill();
888
+ } else {
889
+ backfill();
890
+ }
807
891
 
808
892
  if (sessionId) return { action: "ended", sessionId, transcriptPath, backfilled };
809
- if (backfilled) return { action: "backfilled", transcriptPath };
893
+ if (backfilled && transcriptPath !== null) return { action: "backfilled", transcriptPath };
810
894
  return { action: "skipped", reason: "nothing_to_do" };
811
895
  } catch (error) {
812
896
  return { action: "failed", error: error instanceof Error ? error.message : String(error) };
@@ -15,7 +15,6 @@
15
15
  // needs the dated zip backup from the runtime (CV22.DS7.TS1) and refuses, like
16
16
  // Python, when it is not available.
17
17
 
18
- import { backfillCodexSession } from "#conversation/backfill.ts";
19
18
  import { diagnoseJourneyAssociations, renderJourneyFindings } from "#conversation/journeyRepair.ts";
20
19
  import {
21
20
  discardCurrentConversation,
@@ -64,7 +63,6 @@ export const CONVERSATION_LOGGER_SUBCOMMANDS = [
64
63
  "session-maintenance",
65
64
  "diagnose-journeys",
66
65
  "repair-journeys",
67
- "backfill-codex-session",
68
66
  ] as const;
69
67
 
70
68
  /** The subcommands whose Python path runs `end_conversation`'s LLM close tail. */
@@ -189,7 +187,10 @@ export async function runConversationLoggerCommand(
189
187
  }
190
188
  }
191
189
 
192
- case "session-end":
190
+ case "session-end": {
191
+ // `--interface`, as `stop` takes it: the hook's own detection, used only
192
+ // when the session row carries no label (CV21.DS4.TS2).
193
+ const interfaceName = extractOption(args.slice(1), "--interface").value ?? undefined;
193
194
  await handleSessionEndHook(
194
195
  db,
195
196
  context.stdin ?? "",
@@ -197,11 +198,13 @@ export async function runConversationLoggerCommand(
197
198
  mirrorHome: home,
198
199
  claudeProjectDir: runtime.claudeProjectDir,
199
200
  homeDir: runtime.homeDir,
201
+ interface: interfaceName,
200
202
  },
201
203
  deps,
202
204
  await runtime.closeHooks(),
203
205
  );
204
206
  return ok([]);
207
+ }
205
208
 
206
209
  case "log-user":
207
210
  case "log-assistant": {
@@ -300,21 +303,6 @@ export async function runConversationLoggerCommand(
300
303
  return ok(lines);
301
304
  }
302
305
 
303
- case "backfill-codex-session": {
304
- if (args.length < 2) return ok([]);
305
- const path = args[1] as string;
306
- const count = backfillCodexSession(
307
- db,
308
- { jsonlPath: path, interface: interfaceOption(args, "codex") },
309
- deps,
310
- );
311
- return ok([
312
- count
313
- ? `Backfilled 1 Codex session from ${path}`
314
- : `No new Codex session backfilled from ${path}`,
315
- ]);
316
- }
317
-
318
306
  default:
319
307
  return ok([]);
320
308
  }
@@ -7,7 +7,7 @@
7
7
  // that cross the LLM close tail -- the replay providers and the maintenance
8
8
  // dependencies built on them.
9
9
  //
10
- // The providers are loaded lazily. `diagnose-journeys`, `backfill-codex-session`,
10
+ // The providers are loaded lazily. `diagnose-journeys`
11
11
  // or `session-start --fast` must never fail because a replay fixture is not
12
12
  // configured, and `switch` or `session-end` must fail loudly (and fall back
13
13
  // to Python at the routing layer) when it is not: "unconfigured installs keep
@@ -29,6 +29,7 @@ import {
29
29
  resolveExtractionMaxAttempts,
30
30
  resolveExtractionModel,
31
31
  resolveMaintenanceMaxExtractions,
32
+ resolveStaleOrphanMinutes,
32
33
  resolveSummarizeEnabled,
33
34
  resolveTwoPassEnabled,
34
35
  } from "#providers/config.ts";
@@ -242,6 +243,9 @@ export function createLoggerRuntime(options: LoggerRuntimeOptions): LoggerRuntim
242
243
  source: "startup_maintenance",
243
244
  }),
244
245
  runExtraction,
246
+ // CV21.DS4.TS2: the finalization a bounded runtime's SessionEnd deferred.
247
+ finalizeCloseTail: (database, conversationId) =>
248
+ hooks.finalizeMetadata(database, conversationId),
245
249
  // CR121: only the sessions newer than this home; its birth is read from
246
250
  // the ledger once per maintenance run, and an unknown birth is an error.
247
251
  backfillPiSessions: (database) =>
@@ -251,6 +255,7 @@ export function createLoggerRuntime(options: LoggerRuntimeOptions): LoggerRuntim
251
255
  deps,
252
256
  ),
253
257
  extractionLimit: resolveMaintenanceMaxExtractions({ env }),
258
+ staleOrphanMinutes: resolveStaleOrphanMinutes({ env }),
254
259
  monotonic: options.monotonic,
255
260
  now: deps.nowIso,
256
261
  };
@@ -0,0 +1,50 @@
1
+ // What a runtime's session lifecycle lets Mirror do at its edges, by the label
2
+ // US8 stamps on the session row (`claude_code`, `codex`, `grok_build`). One home
3
+ // for the two policies the close path reads (CV21.DS4.TS2, US4).
4
+
5
+ /**
6
+ * Whether a runtime's SessionEnd hook has the time to run the LLM tail at exit
7
+ * (CV21.DS4.TS2). Measured bounds, with the evidence:
8
+ *
9
+ * claude_code the plugin registers the hook `async`; Claude Code runs it in
10
+ * the background after exit, unbounded by the user's wait.
11
+ * codex synchronous ("running async SessionEnd hook synchronously",
12
+ * DS12.TS2 L2), 1 s for a handler with no `timeout` (DS2's live
13
+ * `/hooks` table, 2026-10-13, Codex 0.162.1).
14
+ * grok_build 1.5 s for a handler with no `timeout` (DS12.TS4, Grok 1.0.52).
15
+ * DS10 re-measures it and may flip this entry.
16
+ *
17
+ * Any other label, and no label, keeps today's behavior: the tail runs.
18
+ */
19
+ export function sessionEndRunsLlmTail(interfaceName: string | null | undefined): boolean {
20
+ switch (interfaceName) {
21
+ case "codex":
22
+ case "grok_build":
23
+ return false;
24
+ default:
25
+ return true;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Whether a runtime's session outlives its TUI, so that `active = 1` on the
31
+ * session row means RESUMABLE rather than attended (CV21.DS4.US4).
32
+ *
33
+ * codex 0.163.0: `/quit` and Ctrl-D print "Disconnected from this task.
34
+ * Any running work continues. To reconnect, run: codex resume
35
+ * <id>". The thread persists in the shared app-server daemon, and
36
+ * SessionEnd does not fire at TUI exit: Codex's own log shows no
37
+ * hook started after the last turn, and a probe hook pair recorded
38
+ * nothing on either quit path (US4's probe, 2026-10-17). On 0.162.1,
39
+ * four days earlier, `/quit` ended the session and SessionEnd fired
40
+ * (DS2). The session is still re-measured by the next story that
41
+ * depends on it.
42
+ *
43
+ * For such a runtime, an open conversation idle past the orphan threshold is an
44
+ * orphan even while its session is active, and the close deactivates the session
45
+ * (`closeStaleOrphans`). Every other runtime's session ends with its TUI, and an
46
+ * active session there means a person who may still be typing.
47
+ */
48
+ export function sessionOutlivesTui(interfaceName: string | null | undefined): boolean {
49
+ return interfaceName === "codex";
50
+ }
@@ -16,6 +16,7 @@
16
16
  // report still fails. Freezing real timings would have faked the evidence
17
17
  // instead of isolating it.
18
18
 
19
+ import { finishDeferredCloseTails } from "#conversation/deferredCloseTail.ts";
19
20
  import {
20
21
  countCarriedOverConversations,
21
22
  countConversationsWithExtractionStatus,
@@ -23,6 +24,7 @@ import {
23
24
  extractPending,
24
25
  } from "#conversation/extractionDriver.ts";
25
26
  import { setMute } from "#conversation/logger.ts";
27
+ import { sessionOutlivesTui } from "#conversation/runtimePolicy.ts";
26
28
  import type { WritableDatabase } from "#db/database.ts";
27
29
  import { GLOBAL_STICKY_DEFAULTS_SESSION_ID } from "#mirror/runtimeSession.ts";
28
30
  import { deactivateOperatingMode } from "#mode/operatingMode.ts";
@@ -40,6 +42,12 @@ export interface MaintenanceDeps {
40
42
  retitleConversation: (db: WritableDatabase, conversationId: string) => Promise<boolean> | boolean;
41
43
  /** The DS5 orchestration behind replay with its accounting, as the driver injects it. */
42
44
  runExtraction: (db: WritableDatabase, conversationId: string) => Promise<void> | void;
45
+ /**
46
+ * The close-time finalization (title, summary, tags) for a conversation a
47
+ * bounded runtime's SessionEnd closed without it (CV21.DS4.TS2). Optional
48
+ * only for callers written before the step existed; the front door wires it.
49
+ */
50
+ finalizeCloseTail?: (db: WritableDatabase, conversationId: string) => Promise<void> | void;
43
51
  /**
44
52
  * `backfillPiSessions` (slice E) bound to its resolved sessions directory.
45
53
  * Injected rather than resolved here so the composite never reads the
@@ -48,6 +56,8 @@ export interface MaintenanceDeps {
48
56
  backfillPiSessions: (db: WritableDatabase) => Promise<number> | number;
49
57
  /** Python's `MEMORY_MAINTENANCE_MAX_EXTRACTIONS` (AI-05); defaults to the released budget. */
50
58
  extractionLimit?: number;
59
+ /** `MIRROR_STALE_ORPHAN_MINUTES` (CV21.DS4.US4); defaults to the 30-minute threshold. */
60
+ staleOrphanMinutes?: number;
51
61
  /** Monotonic seconds, injected so report timings are pinnable. */
52
62
  monotonic?: () => number;
53
63
  now?: () => string;
@@ -64,14 +74,38 @@ export function resetSessionOrientation(db: WritableDatabase, now: () => string
64
74
  );
65
75
  }
66
76
 
67
- /** Conversation ids bound to an active runtime session. */
68
- function activeConversationIds(db: WritableDatabase): Set<string> {
77
+ /**
78
+ * Conversation ids bound to an ATTENDED active runtime session: one whose
79
+ * runtime ends the session with its TUI, so `active = 1` means a person may
80
+ * still be typing. A runtime whose session outlives the TUI (Codex 0.163.0,
81
+ * `runtimePolicy.ts`) is left out: there `active` means resumable, and an idle
82
+ * conversation is an orphan like any other (CV21.DS4.US4).
83
+ */
84
+ function attendedConversationIds(db: WritableDatabase): Set<string> {
69
85
  const rows = db
70
86
  .prepare(
71
- "SELECT conversation_id FROM runtime_sessions WHERE active = 1 AND conversation_id IS NOT NULL",
87
+ "SELECT conversation_id, interface FROM runtime_sessions WHERE active = 1 AND conversation_id IS NOT NULL",
72
88
  )
73
89
  .all() as Record<string, unknown>[];
74
- return new Set(rows.map((row) => String(row.conversation_id)));
90
+ return new Set(
91
+ rows
92
+ .filter(
93
+ (row) => !sessionOutlivesTui(typeof row.interface === "string" ? row.interface : null),
94
+ )
95
+ .map((row) => String(row.conversation_id)),
96
+ );
97
+ }
98
+
99
+ /**
100
+ * The runtime sessions still active on a conversation just closed as an orphan:
101
+ * marked inactive with `closed_at`, so `guessRuntimeSessionId` stops reading
102
+ * them as "the session" (DS12.TS2 L9's class), and a later prompt on the same
103
+ * id binds a new conversation.
104
+ */
105
+ function deactivateSessionsOf(db: WritableDatabase, conversationId: string, now: string): void {
106
+ db.prepare(
107
+ "UPDATE runtime_sessions SET active = 0, closed_at = ?, updated_at = ? WHERE conversation_id = ? AND active = 1",
108
+ ).run(now, now, conversationId);
75
109
  }
76
110
 
77
111
  /** Python's `get_open_conversations_idle_since`. */
@@ -91,7 +125,10 @@ function openConversationsIdleSince(db: WritableDatabase, threshold: string): st
91
125
 
92
126
  /**
93
127
  * Python's `close_stale_orphans`: end open conversations idle longer than the
94
- * threshold, skipping any still bound to an active runtime session.
128
+ * threshold, skipping any still bound to an attended active runtime session
129
+ * (`attendedConversationIds`). Since CV21.DS4.US4 a session on a runtime that
130
+ * outlives its TUI does not protect its conversation, and the close
131
+ * deactivates every session the orphan was bound to.
95
132
  *
96
133
  * The count increments even when closing raises: `ended_at` is written before
97
134
  * the tail, so the orphan IS closed and an extraction failure must not stop the
@@ -109,15 +146,16 @@ export async function closeStaleOrphans(
109
146
  const now = deps.now ?? nowIso;
110
147
  const threshold = new Date(Date.parse(now()) - thresholdMinutes * 60_000).toISOString();
111
148
 
112
- const active = activeConversationIds(db);
149
+ const attended = attendedConversationIds(db);
113
150
  let count = 0;
114
151
  for (const conversationId of openConversationsIdleSince(db, threshold)) {
115
- if (active.has(conversationId)) continue;
152
+ if (attended.has(conversationId)) continue;
116
153
  try {
117
154
  await deps.closeConversation(db, conversationId);
118
155
  } catch {
119
156
  // The orphan is closed regardless; a failed tail must not stop the loop.
120
157
  }
158
+ deactivateSessionsOf(db, conversationId, now());
121
159
  count += 1;
122
160
  }
123
161
  return count;
@@ -142,6 +180,8 @@ export async function retitlePendingConversations(
142
180
  `SELECT c.id
143
181
  FROM conversations c
144
182
  WHERE c.ended_at IS NOT NULL
183
+ -- Owed its deferred finalization, which titles it later this run (CV21.DS4.US4).
184
+ AND (c.metadata IS NULL OR json_extract(c.metadata, '$.close_tail_pending') IS NOT 1)
145
185
  AND EXISTS (SELECT 1 FROM messages m WHERE m.conversation_id = c.id AND m.role = 'user')
146
186
  AND EXISTS (SELECT 1 FROM messages m WHERE m.conversation_id = c.id AND m.role = 'assistant')
147
187
  ORDER BY c.ended_at DESC, c.started_at DESC
@@ -203,7 +243,7 @@ export async function sessionMaintenance(
203
243
  closeStaleOrphans(db, {
204
244
  closeConversation: deps.closeConversation,
205
245
  now: deps.now,
206
- thresholdMinutes: STALE_ORPHAN_THRESHOLD_MINUTES,
246
+ thresholdMinutes: deps.staleOrphanMinutes ?? STALE_ORPHAN_THRESHOLD_MINUTES,
207
247
  }),
208
248
  ),
209
249
  await timedStep("Backfilled Pi sessions", monotonic, () => deps.backfillPiSessions(db)),
@@ -213,6 +253,12 @@ export async function sessionMaintenance(
213
253
  await timedStep("Extracted pending conversations", monotonic, () =>
214
254
  extractPending(db, { runExtraction: deps.runExtraction, limit: deps.extractionLimit }),
215
255
  ),
256
+ // After extraction, so a stored summary is kept rather than generated twice.
257
+ await timedStep("Finished deferred close tails", monotonic, () =>
258
+ deps.finalizeCloseTail
259
+ ? finishDeferredCloseTails(db, { finalizeCloseTail: deps.finalizeCloseTail })
260
+ : 0,
261
+ ),
216
262
  ];
217
263
 
218
264
  const parts = ["Conversation maintenance complete."];
@@ -373,10 +373,11 @@ const TS_CONVERSATION_LOGGER_SUBCOMMANDS = new Set([
373
373
  // CV21.DS2.US8: the Stop hook's reply, deterministic like user-prompt.
374
374
  "stop",
375
375
  "discard-current",
376
- // US10 slice F, group 2 (need slice E): diagnose, dry-run repair, Codex import.
376
+ // US10 slice F, group 2 (need slice E): diagnose and dry-run repair. (The
377
+ // Codex import routed here too, until CV21.DS4.TS5 retired it with the
378
+ // v0.32.1 Codex wrapper.)
377
379
  "diagnose-journeys",
378
380
  "repair-journeys",
379
- "backfill-codex-session",
380
381
  ]);
381
382
 
382
383
  // CV22.DS7.US10 slice F. These reach Python's `end_conversation`, which runs
@@ -26,6 +26,7 @@ import {
26
26
  diagnoseRuntime,
27
27
  hookFailureFindings,
28
28
  hookNodeFindings,
29
+ pluginWiringFindings,
29
30
  probeModelPins,
30
31
  renderRuntimeDiagnosis,
31
32
  rootStateFindings,
@@ -301,12 +302,19 @@ export async function runRuntimeReadRoute(
301
302
  if (subcommand === "diagnose") {
302
303
  // The install's own tree, so its worktree findings are Mirror's and never
303
304
  // the cwd project's (CR138).
305
+ const install = io.install ?? detectThisInstall();
304
306
  const report = buildRuntimeStatus({
305
307
  mirrorHome: optionValue(args, "--mirror-home"),
306
308
  env,
307
309
  version,
308
- install: io.install ?? detectThisInstall(),
310
+ install,
309
311
  });
312
+ const installRoot =
313
+ install.kind === "clone"
314
+ ? install.repository
315
+ : install.kind === "package"
316
+ ? install.root
317
+ : null;
310
318
  const findings = [
311
319
  ...diagnoseRuntime(report, inspectGitWorktree(report.git.repository)),
312
320
  ...rootStateFindings(homesRoot(env)),
@@ -324,6 +332,10 @@ export async function runRuntimeReadRoute(
324
332
  return false;
325
333
  }
326
334
  }),
335
+ // CV21.DS4.TS6: the plugin wiring. Codex's versioned cache goes stale
336
+ // after an npm update; Claude Code's link can dangle. Absence is not
337
+ // drift, so an unwired machine stays green.
338
+ ...pluginWiringFindings(installRoot, env),
327
339
  // No catalog provider until DS8: inconclusive, which is also the
328
340
  // oracle's answer when it cannot reach OpenRouter.
329
341
  ...(await probeModelPins(null, "")),
@@ -1,5 +1,6 @@
1
- // Generate the canonical Mirror Mind Claude plugin (manifest + skills), and the
2
- // Claude Code copies of Pi-sourced skills.
1
+ // Generate the canonical Mirror Mind Claude plugin (manifest + skills), the
2
+ // Codex marketplace that names it (CV21.DS4.US1), and the Claude Code copies of
3
+ // Pi-sourced skills.
3
4
  //
4
5
  // The Node port of `src/memory/plugins/claude.py` and its wrapper
5
6
  // `scripts/build_claude_plugin.py` (CV22.DS10.TS5, slice B).
@@ -43,6 +44,12 @@ export const SKILLS_SOURCE_DIR = ".claude/skills";
43
44
  export const PLUGIN_DIR = "plugins/mirror-mind";
44
45
  export const SKILL_FILENAME = "SKILL.md";
45
46
 
47
+ /**
48
+ * Where Codex reads a marketplace from, under the marketplace root, which for
49
+ * the shipped package is the package root (CV21.DS4.US1; DS12.TS2 static 5).
50
+ */
51
+ export const MARKETPLACE_PATH = ".agents/plugins/marketplace.json";
52
+
46
53
  /** A file the generator owns, addressed relative to the repo root. */
47
54
  export interface GeneratedFile {
48
55
  readonly relativePath: string;
@@ -142,6 +149,35 @@ export function manifestJson(version: string): string {
142
149
  return `${JSON.stringify(buildManifest(version), null, 2)}\n`;
143
150
  }
144
151
 
152
+ /**
153
+ * The Codex marketplace the package ships (CV21.DS4.US1).
154
+ *
155
+ * `codex plugin marketplace add <package root>` reads it, and
156
+ * `codex plugin add mirror-mind@mirror-mind` copies the plugin it names into
157
+ * Codex's cache, at the version the plugin manifest carries: the marketplace
158
+ * itself has none. One local source, the plugin directory, relative to the
159
+ * root. Generated beside the manifest so the plugin's name lives in one
160
+ * constant and `--check` guards both files.
161
+ */
162
+ export function buildMarketplace(): Record<string, unknown> {
163
+ return {
164
+ name: PLUGIN_NAME,
165
+ owner: { name: PLUGIN_AUTHOR },
166
+ plugins: [
167
+ {
168
+ name: PLUGIN_NAME,
169
+ source: { source: "local", path: `./${PLUGIN_DIR}` },
170
+ description: PLUGIN_DESCRIPTION,
171
+ },
172
+ ],
173
+ };
174
+ }
175
+
176
+ /** The marketplace as deterministic JSON text. */
177
+ export function marketplaceJson(): string {
178
+ return `${JSON.stringify(buildMarketplace(), null, 2)}\n`;
179
+ }
180
+
145
181
  /**
146
182
  * The skill markdown file, matching `skill.md` case-insensitively.
147
183
  *
@@ -232,6 +268,7 @@ export function planGeneratedFiles(repoRoot: string): GeneratedFile[] {
232
268
  relativePath: join(PLUGIN_DIR, ".claude-plugin", "plugin.json"),
233
269
  content: manifestJson(version),
234
270
  },
271
+ { relativePath: MARKETPLACE_PATH, content: marketplaceJson() },
235
272
  ...claudeCopies.values(),
236
273
  ...[...pluginSkills].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([, file]) => file),
237
274
  ];
@@ -28,7 +28,6 @@ export const ALLOWED_PREFIXES: readonly string[] = [
28
28
  ".claude/hooks/",
29
29
  ".gemini/hooks/",
30
30
  "plugins/mirror-mind/",
31
- "scripts/codex-hooks/",
32
31
  ];
33
32
 
34
33
  export const ALLOWED_FILES: readonly string[] = [
@@ -37,7 +36,9 @@ export const ALLOWED_FILES: readonly string[] = [
37
36
  "LICENSE",
38
37
  "AGENTS.md",
39
38
  "ts/README.md",
40
- "scripts/codex-mirror.sh",
39
+ // CV21.DS4.US1: the Codex marketplace, read from the package root. The
40
+ // `.agents/skills/` symlinks beside it are the checkout's and stay out.
41
+ ".agents/plugins/marketplace.json",
41
42
  ];
42
43
 
43
44
  /** Files that must be in every tarball: the entries and one of each runtime's wiring. */
@@ -57,8 +58,7 @@ export const REQUIRED_FILES: readonly string[] = [
57
58
  ".claude/hooks/session-start.sh",
58
59
  ".gemini/hooks/session-start.sh",
59
60
  "plugins/mirror-mind/hooks/hooks.json",
60
- "scripts/codex-hooks/session-start.sh",
61
- "scripts/codex-mirror.sh",
61
+ ".agents/plugins/marketplace.json",
62
62
  ];
63
63
 
64
64
  /** Prefixes that must have at least one file: content the program reads at runtime. */