mulmoterminal 5.0.0 → 5.0.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mulmoterminal",
3
- "version": "5.0.0",
3
+ "version": "5.0.1",
4
4
  "description": "Run multiple Claude Code and Codex sessions in parallel — a browser terminal grid that shows which agent needs you. Local, tmux-backed.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -20,7 +20,15 @@ import { emptyTranscriptScan, foldTranscriptView, trackTurnStarts, transcriptVie
20
20
  import type { TranscriptPage, TranscriptView } from "../../common/transcriptView.js";
21
21
  import { createCodexFold } from "./transcript-view-codex.js";
22
22
  import { createCursorFold } from "./transcript-view-cursor.js";
23
- import { codexRollouts, codexRolloutsHydrated } from "./registry.js";
23
+ import {
24
+ antigravityConversations,
25
+ antigravityConversationsHydrated,
26
+ codexRollouts,
27
+ codexRolloutsHydrated,
28
+ museConversations,
29
+ museConversationsHydrated,
30
+ } from "./registry.js";
31
+ import { grokConversationExists, grokSessionsRoot } from "../agents/grok-session.js";
24
32
  import { codexSessionsRoot } from "../agents/codex-session.js";
25
33
  import { codexRolloutPath } from "../agents/codex-sessions.js";
26
34
  import { cursorTranscriptPath } from "../agents/cursor-sessions.js";
@@ -333,7 +341,68 @@ const TRANSCRIPT_SOURCES: readonly TranscriptSource[] = [claudeSource, codexSour
333
341
  * Derived from the source list rather than listed, so wiring a source removes it from here by
334
342
  * construction. `shell` is excluded deliberately — a shell cell has no conversation and never
335
343
  * will, so the screen IS its content rather than a fallback from something missing. */
336
- const hasReader = (agent: SessionAgent): boolean => TRANSCRIPT_SOURCES.some((source) => source.agent === agent);
344
+ /** Whether a reader here answers for this agent. Exported for the spec that pins `UNREAD_SOURCES`
345
+ * as its exact complement: asserted against a hand-written list instead, the assertion passes when
346
+ * a reader is wired and the agent is left in both lists — which is the one drift it exists to catch,
347
+ * and Codex demonstrated it passing (round 2). Derived from the source list either way, so there is
348
+ * no second place to keep in step. */
349
+ export const hasReader = (agent: SessionAgent): boolean => TRANSCRIPT_SOURCES.some((source) => source.agent === agent);
350
+
351
+ /** The agents with no reader, and how to ask their own stores whether they hold a session (#2116).
352
+ *
353
+ * This exists because the caller's `agentOf` answers from the PROCESS — a live pty, or what tmux
354
+ * reports the pane is running — and both are gone once a session ends. From that moment a grok,
355
+ * muse or antigravity session was told "nothing has been written yet", which is a different and
356
+ * false sentence: their conversation exists, this host just cannot read it.
357
+ *
358
+ * So the same principle the reader is built on applies here too: ASK THE STORE, NOT THE PROCESS.
359
+ * Two of the three answer from a map this server already hydrated off its own append log, which
360
+ * costs nothing; grok keeps no such log, because a grok conversation id IS the session id, so its
361
+ * question is one directory.
362
+ *
363
+ * Asked ONLY when every reader missed AND the process could not say — a live shell cell answers
364
+ * `shell` from its pty and never reaches here.
365
+ *
366
+ * THE LIST MUST BE EXACTLY THE AGENTS `hasReader` SAYS NO TO. When #1822 gives one of them a
367
+ * reader, leaving it here would make a readable session report that it cannot be read; a spec pins
368
+ * the two lists against each other rather than trusting a comment. */
369
+ export interface UnreadSource {
370
+ agent: SessionAgent;
371
+ holds: (cwd: string, id: string) => Promise<boolean>;
372
+ }
373
+
374
+ export const UNREAD_SOURCES: readonly UnreadSource[] = [
375
+ {
376
+ agent: "antigravity",
377
+ holds: async (_cwd, id) => {
378
+ await antigravityConversationsHydrated; // a request racing boot must not read an empty map
379
+ return antigravityConversations.has(id);
380
+ },
381
+ },
382
+ {
383
+ agent: "muse",
384
+ holds: async (_cwd, id) => {
385
+ await museConversationsHydrated;
386
+ return museConversations.has(id);
387
+ },
388
+ },
389
+ // The cwd IS the question for grok: its sessions are filed per directory, and the one on screen is
390
+ // the only one this cell could be showing.
391
+ { agent: "grok", holds: (cwd, id) => Promise.resolve(grokConversationExists(grokSessionsRoot(), cwd, id)) },
392
+ ];
393
+
394
+ /** Which reader-less agent holds this session, or null when none does.
395
+ *
396
+ * The sources are a PARAMETER for the same reason the byte window is one: two of the three answer
397
+ * from a map this process hydrates from `~/.mulmoterminal`, and a spec that planted an entry there
398
+ * the way production does would be writing into the machine's real log — which is exactly what the
399
+ * first version of its spec did. Injected, the same test says the same thing and touches nothing. */
400
+ export async function unreadOwner(cwd: string, id: string, sources: readonly UnreadSource[] = UNREAD_SOURCES): Promise<SessionAgent | null> {
401
+ for (const source of sources) {
402
+ if (await source.holds(cwd, id)) return source.agent;
403
+ }
404
+ return null;
405
+ }
337
406
 
338
407
  // ── the cursor a client pages with (#2112) ───────────────────────────────────────────────────
339
408
  //
@@ -443,6 +512,11 @@ async function pageFromFile(
443
512
  * Injected rather than imported: `agentOfSession` lives in server/index.ts, which imports this. */
444
513
  export interface TranscriptViewDeps {
445
514
  window?: TranscriptWindow;
515
+ /** The reader-less agents' stores, for the one question `agentOf` cannot answer once the process
516
+ * is gone (#2116). Injected like the window above, and for the same reason: the real ones are
517
+ * maps hydrated from `~/.mulmoterminal`, so a spec exercising them through production's own
518
+ * writer would append to the machine's real log. */
519
+ unreadSources?: readonly UnreadSource[];
446
520
  /** This session's agent, when the host knows it. Consulted ONLY after every source has missed —
447
521
  * never to choose a reader (see TranscriptSource). */
448
522
  agentOf?: (id: string) => SessionAgent | null;
@@ -509,7 +583,11 @@ export async function sessionTranscriptPage(
509
583
  // The old signature took the window positionally and several specs still do. Kept rather than
510
584
  // migrated: the window is the only thing those specs vary, and a second parameter shape is
511
585
  // cheaper than touching every one of them.
512
- const { window = DEFAULT_TRANSCRIPT_WINDOW, agentOf } = "tailBytes" in deps ? { window: deps, agentOf: undefined } : deps;
586
+ const {
587
+ window = DEFAULT_TRANSCRIPT_WINDOW,
588
+ agentOf,
589
+ unreadSources = UNREAD_SOURCES,
590
+ } = "tailBytes" in deps ? { window: deps, agentOf: undefined, unreadSources: undefined } : deps;
513
591
  if (!cwd || !SESSION_ID_RE.test(id)) return { view: { status: "none" }, older: null };
514
592
  // Before any file is opened. `/clear` makes claude mint a new id and a new transcript while hooks
515
593
  // keep reporting under ours, so from that moment `${id}.jsonl` holds the conversation the user
@@ -531,6 +609,9 @@ export async function sessionTranscriptPage(
531
609
  // sentences — never to choose a reader. Asking here is safe precisely because it is last: the
532
610
  // restarted-claude case (reported as `shell`) has already been answered by claude's file.
533
611
  const agent = agentOf?.(id) ?? null;
534
- const unread = agent !== null && agent !== "shell" && !hasReader(agent);
535
- return { view: unread ? { status: "not-supported" } : { status: "none" }, older: null };
612
+ if (agent !== null) return { view: agent !== "shell" && !hasReader(agent) ? { status: "not-supported" } : { status: "none" }, older: null };
613
+ // The process is gone, so it cannot say what it was. The stores of the reader-less agents can, and
614
+ // they outlive it (#2116).
615
+ const owner = await unreadOwner(cwd, id, unreadSources);
616
+ return { view: owner === null ? { status: "none" } : { status: "not-supported" }, older: null };
536
617
  }