SLOPSHOPPER

marimo

Follows the live marimo notebook open under the project: what is running on the status line, the notebook's current state beside each prompt.

guardcommandstatuspromptprocessnetwork
v0.1.0MITupdated 2026-10-10justmytwospence/claude-marimo
A shopper browsing a rack in a slop shop
Preview could not run: harness timed out after 15s
README

claude-marimo

A Claude Code mod that follows the marimo notebooks open under your project, so Claude knows what they look like and what they are doing without being asked. The Claude Code port of pi-marimo; hooks/core is pi-marimo's src/core, copied unchanged.

  • Status line under the prompt: marimo: fit.py · running Data loading › Model fit (12s) · 1 error · also prep.py, plots.py. The first notebook is the current one (used most recently), with what its kernel is doing: the running part is the markdown section (heading path) the running cell sits under, so you can tell roughly what is running. also names the other notebooks followed.
  • Context: each prompt carries the state of every followed notebook beside it. The current one comes first and in full: its outline (markdown headings), one line per code cell with what it defines, and what needs attention (running, queued, errors, stale, edited but not rerun, changed by you in the browser since Claude's last turn). The others follow as short outlines with only the cells that need attention.
  • /marimo: status and the open notebooks; /marimo show prints the block Claude sees; /marimo auto, /marimo off, or /marimo <path> [<path>...] to pin a set of notebooks.
  • herdr: when a turn ends while a cell Claude started is still running, the pane token marimo says what runs (fit.py: Model fit) until the kernel goes quiet, then a herdr notification says it finished, with the done sound unless the pane is focused. Add $marimo to a row in [ui.sidebar.agents] to show the token. The pane's state stays idle: herdr reads Claude Code's state from the screen and takes none from other reporters. See pi-marimo's README.

It pairs with the marimo-pair skill, which is how Claude inspects and changes the notebook; this mod only reads.

Only the latest copy

The state is attached at prompt.submit as context, and each block is tagged with a revision. A prompt.attachment hook answers null for every block but the latest, and the mod invalidates attachments after each prompt, so the conversation holds one copy of the state rather than one per prompt. After a resume or reload only the next prompt's block is kept.

This differs from the pi and opencode ports, which add the block to each model request without ever storing it. A mod cannot change the messages of a request (turn.step pins them), so here the state is refreshed once per prompt, not before every tool follow-up, and dropping the previous copy means the conversation after it is read uncached once per prompt (usually the last turn).

How it works

The core subscribes to the notebook session's /sse stream as a marimo kiosk consumer: a read-only viewer that cannot run or edit code and never takes the notebook over from your browser. A mod runs without Node APIs, so the core reaches the host through $: the server registry with $.fs, /api/sessions with $.http.fetch, and the stream through curl under $.process.spawn ($.http.fetch buffers whole bodies). See pi-marimo's README for the rest.

It follows every notebook open under the session's working directory (up to eight, leaving out hidden directories such as .claude/worktrees/). The current one is the one Claude last worked in, read from its marimo-pair calls (tool.call: --file, --session, or --url when only one followed notebook is on that server), so two Claude sessions in two notebooks each keep their own. Before Claude has touched any, it is the one used most recently by anyone: a running cell first, then the latest cell run or edit. Pin a set with /marimo <path> <path> or MARIMO_NOTEBOOK=/path/a.py,/path/b.py (off disables the mod). Token-protected servers are reached with MARIMO_TOKEN.

Install

A plugin directory with a hooks module, loaded with claude --plugin-dir <checkout> or listed in CLAUDE_CODE_PLUGIN_DIRS. Needs curl and realpath on PATH. Tested with Claude Code 2.1.289.

Development

claude plugin validate .   # static analysis of the hooks module
claude plugin test         # tests/, no session or network
tsc -p .                   # after one load, which writes .claude-plugin/types/
Source 10 files
hooks/register.ts 312 lines
1// marimo: follows the live marimo notebook open under the project.
2//
3// - Status line under the prompt: `marimo: fit.py · running Data loading › Model fit (12s) · 1 error`.
4// - Context: each prompt carries the notebook's current state beside it (prompt.submit's context).
5//   Older copies are left out of every later request (prompt.attachment answers null for them),
6//   so the conversation holds one copy, the latest, instead of one per prompt.
7// - /marimo: status, show, auto, off, or a notebook path to follow.
8// - herdr: when a turn ends with a cell Claude started still running, the pane token `marimo`
9//   says what runs until the kernel goes quiet, then a notification says it finished.
10//
11// The port of pi-marimo; core/ is shared with it unchanged. The host reads on(...) and
12// $.noun.method(...) from source, so every $ call is spelled in full in this file and the
13// core reaches the host only through the Io built in claudeIo.
14import type { EngineInterface, On } from 'claude-code'
15
16import { HERDR_SOURCE, HERDR_TOKEN, type HoldChange, KernelHold } from './core/hold.js'
17import { type Cancellable, type Io, normalize, type StreamEnd } from './core/io.js'
18import { type SnapshotEntry, snapshot, STATE_TAG, statusParts, statusText } from './core/render.js'
19import { pairTargets } from './core/touch.js'
20import { MarimoWatcher, type Mode } from './core/watcher.js'
21
22const COMMAND = 'marimo'
23
24/** What the hooks share across a session. */
25interface State {
26  watcher?: MarimoWatcher
27  /** Browser edits after this change number are flagged as new in the next prompt's state. */
28  /** Per notebook path: browser edits after this change number are flagged as new in the next state. */
29  seen: Map<string, number>
30  /** Revisions of the state this process attached are `<nonce>-<n>`; only the latest is kept. */
31  nonce: string
32  revision: number
33  ticker?: { cancel: () => void }
34  lastStatus?: string
35  /** A cell Claude started that outlives its turn, shown in herdr. */
36  hold: KernelHold
37  /** herdr reports, in order. */
38  herdr: Promise<unknown>
39  /** Whether the `marimo` token is set. */
40  holding: boolean
41}
42
43export function register(on: On): void {
44  const s: State = { seen: new Map(), nonce: Math.random().toString(36).slice(2, 8), revision: 0, hold: new KernelHold(), herdr: Promise.resolve(), holding: false }
45
46  on('session.start', async ($, e, next) => {
47    const started = await next(e)
48    await $.command.register({
49      name: COMMAND,
50      description: 'marimo: status, show, auto, off, or a notebook path to follow',
51      argumentHint: '[status|show|auto|off|<path>]',
52      immediate: true,
53    })
54    const cwd = await $.session.cwd()
55    const token = (await $.env.get('MARIMO_TOKEN'))?.trim() || undefined
56    const pinned = (await $.env.get('MARIMO_NOTEBOOK'))?.trim()
57    startWatcher($, s, cwd, token, pinned)
58    return started
59  })
60
61  on('session.end', async ($, e, next) => {
62    await herdr($, s, s.hold.release())
63    s.ticker?.cancel()
64    s.ticker = undefined
65    await s.watcher?.stop()
66    s.watcher = undefined
67    return next(e)
68  })
69
70  on('prompt.submit', async ($, e, next) => {
71    const watcher = s.watcher
72    if (!watcher) return next(e)
73    watcher.refresh()
74    await watcher.settle(2000)
75    const entries = stateEntries(watcher, s)
76    if (!entries.length) return next(e)
77    s.revision++
78    const text = stateText(entries, `${s.nonce}-${s.revision}`)
79    const result = await next({ ...e, context: [...(e.context ?? []), text] })
80    // Ask every attachment again, so the copies this one supersedes are left out.
81    $.ui.invalidate('prompt.attachment')
82    return result
83  })
84
85  on('prompt.attachment', ($, e, next) => {
86    if (e.origin.kind === 'plugin' && isStaleState(e.text, `${s.nonce}-${s.revision}`)) return { text: null }
87    return next(e)
88  })
89
90  // The notebook this session's agent works in (through marimo-pair) becomes current for it,
91  // whatever other sessions or the browser do; subagents' calls count as this session's.
92  on('tool.call', ($, e, next) => {
93    const targets = pairTargets(JSON.stringify(e))
94    if (targets.length) s.watcher?.touch(targets)
95    return next(e)
96  })
97
98  on('turn.start', ($, e, next) => {
99    void herdr($, s, s.hold.begin(Date.now()))
100    return next(e)
101  })
102
103  on('turn.complete', async ($, e, next) => {
104    if (e.agentId !== undefined) return next(e)
105    const followed = s.watcher?.followed() ?? []
106    for (const f of followed) s.seen.set(f.attachment.path, f.notebook.seq)
107    void herdr($, s, s.hold.end(followed, Date.now()))
108    return next(e)
109  })
110
111  on('command.run', { command: COMMAND }, async ($, e) => {
112    const watcher = s.watcher
113    if (!watcher) return { text: 'marimo: not started' }
114    const arg = e.args.trim()
115    if (arg === 'auto' || arg === 'off') {
116      watcher.setMode(modeFor(arg === 'off' ? 'off' : undefined))
117      return { text: arg === 'off' ? 'marimo: off' : 'marimo: following every notebook open under this directory' }
118    }
119    if (arg === 'show') {
120      const entries = stateEntries(watcher, s)
121      return { text: entries.length ? snapshot(entries, { refresh: 'prompt' }) : statusFor(watcher) ?? 'marimo: no notebook attached' }
122    }
123    if (arg && arg !== 'status') {
124      const cwd = await $.session.cwd()
125      const paths = arg.split(/[\s,]+/).filter(Boolean).map((p) => normalize(p, cwd))
126      watcher.setMode({ kind: 'pinned', paths })
127      return { text: `marimo: following ${paths.join(', ')}` }
128    }
129    const open = await watcher.list()
130    return {
131      text: [
132        statusFor(watcher) ?? 'marimo: no notebook attached',
133        `Mode: ${watcher.mode.kind}${watcher.mode.kind === 'pinned' ? ` (${watcher.mode.paths.join(', ')})` : ''}.`,
134        open.length ? `Open notebooks:\n${open.map((n) => `  ${n.path}  (${n.url}, session ${n.sessionId})`).join('\n')}` : 'No marimo notebooks are open.',
135      ].join('\n'),
136    }
137  })
138
139}
140
141function startWatcher($: EngineInterface, s: State, cwd: string, token: string | undefined, pinned: string | undefined): void {
142  s.watcher = new MarimoWatcher({ io: claudeIo($), cwd, token, onChange: () => showStatus($, s) })
143  s.watcher.mode = modeFor(pinned)
144  s.watcher.start()
145}
146
147/** Pin the status line; tick it every second while a cell runs. */
148function showStatus($: EngineInterface, s: State): void {
149  const watcher = s.watcher
150  if (!watcher) return
151  if (s.hold.active) void herdr($, s, s.hold.update(watcher.followed(), Date.now()))
152  const text = statusFor(watcher)
153  if (text !== s.lastStatus) {
154    s.lastStatus = text
155    // The host labels a plugin's status line with the plugin's name ("marimo: ").
156    $.ui.status(text?.replace(/^marimo: /, ''))
157  }
158  const running = watcher.connection === 'connected' && watcher.notebook.running()
159  if (running && !s.ticker) s.ticker = $.clock.every(1000, () => showStatus($, s))
160  if (!running && s.ticker) {
161    s.ticker.cancel()
162    s.ticker = undefined
163  }
164}
165
166/**
167 * The herdr CLI calls for a hold change. herdr's Claude integration only reports the session and
168 * herdr reads Claude Code's state from the screen; it takes no state from any other reporter once
169 * that session is known, so the pane stays `idle`. While held, the pane token `marimo` says what
170 * runs; when the run ends it is cleared, and a notification follows unless the pane is focused.
171 */
172export function herdrCalls(change: HoldChange, pane: string, reported: boolean): string[][] {
173  const metadata = ['herdr', 'pane', 'report-metadata', pane, '--source', HERDR_SOURCE, '--agent', 'claude']
174  if (change.kind === 'none') return []
175  if (change.kind === 'held') return [[...metadata, '--token', `${HERDR_TOKEN}=${change.value}`, '--ttl-ms', '86400000']]
176  return reported ? [[...metadata, '--clear-token', HERDR_TOKEN]] : []
177}
178
179/** Report a hold change to herdr, inside a herdr pane only; any failure is ignored. */
180function herdr($: EngineInterface, s: State, change: HoldChange): Promise<unknown> {
181  if (change.kind === 'none') return s.herdr
182  s.herdr = s.herdr.then(async () => {
183    if ((await $.env.get('HERDR_ENV')) !== '1') return
184    const pane = await $.env.get('HERDR_PANE_ID')
185    if (!pane) return
186    const calls = herdrCalls(change, pane, s.holding)
187    s.holding = change.kind === 'held'
188    for (const argv of calls) await $.process.run(argv, { timeoutMs: 3_000 }).catch(() => undefined)
189    if (change.kind !== 'finished') return
190    const got = await $.process.run(['herdr', 'pane', 'get', pane], { timeoutMs: 3_000 }).catch(() => undefined)
191    if (got && got.exitCode === 0 && /"focused":true/.test(got.stdout)) return
192    await $.process.run(['herdr', 'notification', 'show', change.title, '--body', change.body, '--sound', 'done'], { timeoutMs: 3_000 }).catch(() => undefined)
193  }).catch(() => undefined)
194  return s.herdr
195}
196
197/**
198 * MARIMO_NOTEBOOK=<path>[,<path>...] follows those notebooks, =off turns the mod off; unset follows
199 * every notebook open under the project.
200 */
201export function modeFor(pinned: string | undefined): Mode {
202  if (pinned === 'off') return { kind: 'off' }
203  const paths = (pinned ?? '').split(',').map((p) => p.trim()).filter(Boolean)
204  return paths.length ? { kind: 'pinned', paths } : { kind: 'auto' }
205}
206
207/** The followed notebooks that are connected, the current one first. */
208export function stateEntries(watcher: MarimoWatcher, s: { seen: Map<string, number> }) {
209  return watcher.followed()
210    .filter((f) => f.connection === 'connected' && f.notebook.ready)
211    .map((f) => ({ notebook: f.notebook, attachment: f.attachment, current: f.current, seenSeq: s.seen.get(f.attachment.path) ?? 0 }))
212}
213
214export function statusFor(watcher: MarimoWatcher): string | undefined {
215  const { connection, attachment, mode } = watcher
216  if (!attachment) return undefined
217  if (connection === 'searching') {
218    return mode.kind === 'pinned' ? statusText({ notebook: mode.paths.map((p) => p.split('/').pop()).join(', '), connection: 'not open', queued: 0, errors: 0 }) : undefined
219  }
220  return statusText(statusParts(watcher.notebook, attachment, connection, Date.now(), watcher.others()))
221}
222
223/** The state block, tagged with its revision so later requests can tell it is superseded. */
224export function stateText(entries: SnapshotEntry[], rev: string): string {
225  return snapshot(entries, { refresh: 'prompt' }).replace(`<${STATE_TAG} `, `<${STATE_TAG} rev="${rev}" `)
226}
227
228/** True for a state block of this plugin that is not the latest revision. */
229export function isStaleState(text: string, latest: string): boolean {
230  const match = new RegExp(`<${STATE_TAG} rev="([^"]+)"`).exec(text)
231  return match !== null && match[1] !== latest
232}
233
234/** The core's host calls, through $: no Node APIs in a mod's sandbox. */
235function claudeIo($: EngineInterface): Io {
236  return {
237    async registryEntries() {
238      const home = (await $.env.get('HOME')) ?? ''
239      const state = (await $.env.get('XDG_STATE_HOME')) || `${home}/.local/state`
240      const dir = `${state}/marimo/servers`
241      const entries: unknown[] = []
242      const files = await $.fs.list(dir).catch(() => [])
243      for (const file of files) {
244        if (file.kind !== 'file' || !file.name.endsWith('.json')) continue
245        try {
246          entries.push(JSON.parse(await $.fs.read(`${dir}/${file.name}`)))
247        } catch {
248          // A file being written or removed; skip it this round.
249        }
250      }
251      return entries
252    },
253
254    async getJson(url, headers, timeoutMs) {
255      const request = $.http.fetch(url, { headers }).then(
256        (response) => (response.ok ? JSON.parse(response.text) : undefined),
257        () => undefined,
258      )
259      const timeout = $.clock.sleep(timeoutMs).then(() => undefined)
260      return Promise.race([request, timeout]).catch(() => undefined)
261    },
262
263    // $.http.fetch buffers a whole body, so the SSE stream is read through curl.
264    stream(url, headers, onText): Cancellable<StreamEnd> {
265      const argv = ['curl', '--silent', '--no-buffer', '--fail']
266      for (const [name, value] of Object.entries(headers)) argv.push('--header', `${name}: ${value}`)
267      argv.push(url)
268      const child = $.process.spawn({ argv })
269      let received = false
270      let cancelled = false
271      const done = (async (): Promise<StreamEnd> => {
272        try {
273          for await (const chunk of child) {
274            if (cancelled) break
275            if (chunk.stream !== 'stdout') continue
276            received = true
277            onText(chunk.text)
278          }
279        } catch {
280          return { ok: cancelled }
281        }
282        return { ok: received || cancelled }
283      })()
284      return {
285        done,
286        cancel: () => {
287          cancelled = true
288          void child.return(undefined as never).catch(() => undefined)
289        },
290      }
291    },
292
293    async realpath(path) {
294      const result = await $.process.run(['realpath', path]).catch(() => undefined)
295      const real = result && result.exitCode === 0 ? result.stdout.trim() : ''
296      return real || path
297    },
298
299    sleep(ms): Cancellable<void> {
300      let cancel = (): void => {}
301      const done = new Promise<void>((resolve) => {
302        const timer = $.clock.after(ms, resolve)
303        cancel = () => {
304          timer.cancel()
305          resolve()
306        }
307      })
308      return { done, cancel }
309    },
310  }
311}
312
hooks/core/hold.ts 103 lines
1// The kernel hold: the stretch after the agent's turn ends while a cell it
2// started is still running. It begins when a turn ends with a notebook the agent
3// worked in during that turn busy (a cell running or queued), and ends when
4// every held notebook is quiet, disconnected or no longer followed, or when the
5// next turn begins. The hosts show it in herdr; this file only decides it.
6
7import { basename } from "./io.js";
8import { truncate } from "./notebook.js";
9import { elapsed } from "./render.js";
10import type { Followed } from "./watcher.js";
11
12/** The herdr pane token the ports report while the kernel holds (`$marimo` in a sidebar row). */
13export const HERDR_TOKEN = "marimo";
14/** The herdr metadata source the ports report under. */
15export const HERDR_SOURCE = "marimo";
16
17export type HoldChange =
18  | { kind: "none" }
19  /** The hold began, or what it shows changed. */
20  | { kind: "held"; value: string }
21  /** The hold ended because the kernel went quiet; what to tell the user. */
22  | { kind: "finished"; title: string; body: string }
23  /** The hold ended without the kernel finishing (a new turn, the session ending). */
24  | { kind: "released" };
25
26const NONE: HoldChange = { kind: "none" };
27
28export function busy(f: Followed): boolean {
29  return f.connection === "connected" && (f.notebook.running() !== undefined || f.notebook.queued() > 0);
30}
31
32/** What the pane token says: `fit.py: Model fit`, `fit.py: cell train`, or `fit.py: 3 queued`. */
33export function holdValue(f: Followed): string {
34  const file = basename(f.attachment.path);
35  const run = f.notebook.running();
36  if (!run) return `${file}: ${f.notebook.queued()} queued`;
37  const where = run.section[run.section.length - 1] ?? `cell ${run.cell.name !== "_" ? run.cell.name : run.cell.id}`;
38  return truncate(`${file}: ${where}`, 80);
39}
40
41export class KernelHold {
42  private turnStartedAt: number | undefined;
43  private held = new Set<string>();
44  private since = 0;
45  private last: string | undefined;
46
47  get active(): boolean {
48    return this.held.size > 0;
49  }
50
51  /** The notebooks held, by path. */
52  get paths(): string[] {
53    return [...this.held];
54  }
55
56  /** A turn began: the agent is working again, so any hold ends. */
57  begin(now: number): HoldChange {
58    this.turnStartedAt = now;
59    return this.release();
60  }
61
62  /** The turn ended: hold the busy notebooks the agent worked in during it. */
63  end(followed: Followed[], now: number): HoldChange {
64    const startedAt = this.turnStartedAt;
65    this.turnStartedAt = undefined;
66    if (startedAt === undefined || this.active) return NONE;
67    for (const f of followed) if (f.touchedAt >= startedAt && busy(f)) this.held.add(f.attachment.path);
68    if (!this.active) return NONE;
69    this.since = now;
70    return this.update(followed, now);
71  }
72
73  /** After a notebook changed: what the hold shows now, or that it finished. */
74  update(followed: Followed[], now: number): HoldChange {
75    if (!this.active) return NONE;
76    const held = followed.filter((f) => this.held.has(f.attachment.path));
77    const running = held.filter(busy);
78    if (!running.length) {
79      this.held.clear();
80      this.last = undefined;
81      const files = held.length ? held.map((f) => basename(f.attachment.path)).join(", ") : "notebook";
82      const errors = held.reduce((n, f) => n + (f.connection === "connected" ? f.notebook.errors().length : 0), 0);
83      const gone = held.length < 1 || held.some((f) => f.connection !== "connected");
84      const parts = [`ran ${elapsed(now - this.since)}`];
85      if (errors) parts.push(`${errors} error${errors === 1 ? "" : "s"}`);
86      if (gone) parts.push("disconnected");
87      return { kind: "finished", title: truncate(`${files} ${gone ? "stopped" : "finished"}`, 80), body: parts.join(" · ") };
88    }
89    const value = holdValue(running[0]!);
90    if (value === this.last) return NONE;
91    this.last = value;
92    return { kind: "held", value };
93  }
94
95  /** Stop holding without a notice. */
96  release(): HoldChange {
97    if (!this.active) return NONE;
98    this.held.clear();
99    this.last = undefined;
100    return { kind: "released" };
101  }
102}
103
hooks/core/io.ts 50 lines
1// Everything the core needs from its host, so it runs unchanged under Node and
2// Bun (pi, opencode: node-io.ts) and in sandboxes that offer only their own
3// file, HTTP, process and clock calls (Claude Code mods). The rest of src/core
4// is plain ECMAScript.
5
6export interface Cancellable<T> {
7  done: Promise<T>;
8  cancel(): void;
9}
10
11export interface StreamEnd {
12  /** The server answered and the body ended (or was cancelled). */
13  ok: boolean;
14  status?: number;
15}
16
17export interface Io {
18  /** The parsed JSON of every entry in marimo's server registry; servers known to be dead may be left out. */
19  registryEntries(): Promise<unknown[]>;
20  /** GET a JSON document; undefined on any failure. */
21  getJson(url: string, headers: Record<string, string>, timeoutMs: number): Promise<unknown>;
22  /** GET a streaming body, handing over its text as it arrives. */
23  stream(url: string, headers: Record<string, string>, onText: (text: string) => void): Cancellable<StreamEnd>;
24  /** The path with symlinks resolved (macOS /tmp is /private/tmp); the path itself when that fails. */
25  realpath(path: string): Promise<string>;
26  sleep(ms: number): Cancellable<void>;
27}
28
29/** Posix path helpers, enough for comparing absolute notebook paths. */
30export function normalize(path: string, cwd = "/"): string {
31  const parts: string[] = [];
32  for (const part of (path.startsWith("/") ? path : `${cwd}/${path}`).split("/")) {
33    if (!part || part === ".") continue;
34    if (part === "..") parts.pop();
35    else parts.push(part);
36  }
37  return `/${parts.join("/")}`;
38}
39
40export function basename(path: string): string {
41  return path.replace(/\/+$/, "").split("/").pop() ?? path;
42}
43
44/** Whether path is dir or inside it (both absolute and normalized). */
45export function within(path: string, dir: string): boolean {
46  const d = normalize(dir);
47  const p = normalize(path);
48  return p === d || p.startsWith(d === "/" ? "/" : `${d}/`);
49}
50
hooks/core/render.ts 210 lines
1// Text views of a NotebookState: a one-line status for footers and status lines,
2// and the compact state block injected into the agent's context.
3
4import { basename } from "./io.js";
5import { type Cell, NotebookState, truncate } from "./notebook.js";
6
7export interface Attachment {
8  url: string;
9  sessionId: string;
10  path: string;
11}
12
13export const STATE_TAG = "marimo_notebook_state";
14
15export function elapsed(ms: number): string {
16  const s = Math.max(0, Math.floor(ms / 1000));
17  if (s < 60) return `${s}s`;
18  const m = Math.floor(s / 60);
19  if (m < 60) return `${m}m${String(s % 60).padStart(2, "0")}s`;
20  return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, "0")}m`;
21}
22
23export interface StatusParts {
24  notebook: string;
25  /** "connected" | "connecting" | "disconnected" */
26  connection: string;
27  running?: { section: string; cell: string; elapsed?: string };
28  queued: number;
29  errors: number;
30  /** File names of the other notebooks followed, most recently used first. */
31  others?: string[];
32}
33
34export function statusParts(nb: NotebookState, attachment: Attachment, connection: string, now = Date.now(), others: Attachment[] = []): StatusParts {
35  const run = connection === "connected" ? nb.running() : undefined;
36  return {
37    notebook: basename(attachment.path),
38    connection,
39    running: run
40      ? {
41        section: run.section.join(" › "),
42        cell: run.cell.name !== "_" ? run.cell.name : run.cell.id,
43        elapsed: run.since !== undefined ? elapsed(now - run.since) : undefined,
44      }
45      : undefined,
46    queued: connection === "connected" ? nb.queued() : 0,
47    errors: connection === "connected" ? nb.errors().length : 0,
48    others: others.map((o) => basename(o.path)),
49  };
50}
51
52/**
53 * One line, e.g. `marimo: fit.py · running Data loading › Model fit (12s) · 2 queued · 1 error · also prep.py, plots.py`.
54 * The shape is stable so footers can parse it: `marimo: <current file>`, then ` · ` separated parts
55 * about it, then `also <file>, <file>` naming the other notebooks followed.
56 */
57export function statusText(parts: StatusParts): string {
58  const out = [`marimo: ${parts.notebook}`];
59  if (parts.connection !== "connected") out.push(parts.connection);
60  if (parts.running) {
61    const where = parts.running.section || `cell ${parts.running.cell}`;
62    out.push(`running ${where}${parts.running.elapsed ? ` (${parts.running.elapsed})` : ""}`);
63  }
64  if (parts.queued) out.push(`${parts.queued} queued`);
65  if (parts.errors) out.push(`${parts.errors} error${parts.errors === 1 ? "" : "s"}`);
66  if (parts.others?.length) out.push(`also ${parts.others.join(", ")}`);
67  return out.join(" · ");
68}
69
70function firstLine(code: string): string {
71  const line = code.split("\n").map((l) => l.trim()).find((l) => l && !l.startsWith("#")) ?? "";
72  return truncate(line, 60);
73}
74
75export interface SnapshotOptions {
76  /** When the block is refreshed: before every model request, or once per user prompt. */
77  refresh?: "request" | "prompt";
78  /** Above this many cells, quiet code cells of the current notebook are folded into counts. */
79  maxCells?: number;
80  now?: number;
81}
82
83export interface SnapshotEntry {
84  notebook: NotebookState;
85  attachment: Attachment;
86  current?: boolean;
87  /** Edits from the browser after this change number are flagged as new. */
88  seenSeq?: number;
89}
90
91interface SectionOptions {
92  seen: number;
93  now: number;
94  maxCells: number;
95  /** Deeper headings are left out (for the notebooks that are not current). */
96  maxLevel: number;
97}
98
99/**
100 * One notebook as an outline: headings, then one line per code cell with its
101 * id, what it defines, and anything that needs attention.
102 */
103function section(nb: NotebookState, attachment: Attachment, current: boolean, o: SectionOptions): string[] {
104  const { now, seen } = o;
105  const cells = nb.ordered();
106  const fold = cells.length > o.maxCells;
107  // When most cells are stale (a lazy notebook after a restart), say so once instead of on every line.
108  const staleCount = cells.filter((c) => c.stale).length;
109  const staleCommon = staleCount > cells.length / 2;
110  // Likewise a Run all: the queue is counted on the Kernel line, so list queued cells only when few.
111  const queuedCount = cells.filter((c) => c.status === "queued").length;
112  const queuedCommon = queuedCount > 10 || queuedCount > cells.length / 2;
113
114  const notes = (cell: Cell): string[] => {
115    const out: string[] = [];
116    if (cell.status === "running") out.push(`RUNNING${cell.runningSince ? ` ${elapsed(now - cell.runningSince)}` : ""}`);
117    else if (cell.status === "queued" && !queuedCommon) out.push("queued");
118    if (cell.error) out.push(`ERROR ${cell.error}`);
119    if (cell.blocked) out.push("not run: an ancestor failed");
120    if (cell.disabled) out.push("disabled");
121    else if (cell.status === "disabled-transitively") out.push("disabled by an ancestor");
122    if (cell.stale && !staleCommon) out.push("stale");
123    if (nb.edited(cell)) out.push("edited, not rerun");
124    if (cell.editedBy === "frontend" && cell.editSeq > seen) out.push("changed by the user in the browser");
125    return out;
126  };
127
128  const lines: string[] = [];
129  let folded = 0;
130  const flushFolded = (): void => {
131    if (folded) lines.push(`  … ${folded} more cell${folded === 1 ? "" : "s"}`);
132    folded = 0;
133  };
134  const heading = (level: number, text: string, tail: string): void => {
135    if (level <= o.maxLevel) { flushFolded(); lines.push(`${"#".repeat(level)} ${text}${tail}`); }
136  };
137  for (const cell of cells) {
138    const headings = nb.headings(cell);
139    const cellNotes = notes(cell);
140    if (headings.length && nb.isMarkdown(cell)) {
141      headings.forEach((h, i) => heading(h.level, h.text, i === 0 ? `  [${cell.id}]${cellNotes.length ? ` ${cellNotes.join("; ")}` : ""}` : ""));
142      if (cellNotes.length && !headings.some((h) => h.level <= o.maxLevel)) { flushFolded(); lines.push(`  ${cell.id} (markdown) ${cellNotes.join("; ")}`); }
143      continue;
144    }
145    if (nb.isMarkdown(cell)) {
146      if (cellNotes.length) { flushFolded(); lines.push(`  ${cell.id} (markdown) ${cellNotes.join("; ")}`); }
147      else if (fold) folded++;
148      else lines.push(`  ${cell.id} (markdown)`);
149      continue;
150    }
151    // Folded or not, the outline keeps every heading (down to maxLevel).
152    for (const h of headings) heading(h.level, h.text, `  [${cell.id}]`);
153    if (fold && !cellNotes.length) { folded++; continue; }
154    flushFolded();
155    const defs = nb.defs.get(cell.id) ?? [];
156    const label = cell.name !== "_" ? `${cell.id} "${cell.name}"` : cell.id;
157    const what = defs.length ? `defines ${truncate(defs.join(", "), 80)}` : firstLine(cell.code) || "(empty)";
158    lines.push(`  ${label}: ${what}${cellNotes.length ? ` -- ${cellNotes.join("; ")}` : ""}`);
159  }
160  flushFolded();
161
162  const run = nb.running();
163  const summary: string[] = [];
164  if (run) summary.push(`running ${run.cell.id}${run.section.length ? ` under "${run.section.join(" › ")}"` : ""}`);
165  const queued = nb.queued();
166  if (queued) summary.push(`${queued} queued`);
167  const errors = nb.errors().length;
168  if (errors) summary.push(`${errors} cell${errors === 1 ? "" : "s"} with errors`);
169  if (staleCommon) summary.push(`${staleCount} of ${cells.length} cells stale (inputs changed, not rerun)`);
170
171  return [
172    `=== ${attachment.path} (${current ? "current" : "also followed"}; ${attachment.url}, session ${attachment.sessionId}) ===`,
173    `Kernel: ${summary.length ? summary.join(", ") : "idle"}.`,
174    ...lines,
175  ];
176}
177
178/**
179 * The followed notebooks in one block, the current one (used most recently)
180 * first and in full; the others as a short outline (headings down to ##) with
181 * the cells that need attention.
182 */
183export function snapshot(entries: SnapshotEntry[], options: SnapshotOptions = {}): string {
184  const now = options.now ?? Date.now();
185  const sections = entries.map((e, i) => {
186    const current = e.current ?? i === 0;
187    return section(e.notebook, e.attachment, current, {
188      seen: e.seenSeq ?? Number.POSITIVE_INFINITY,
189      now,
190      maxCells: current ? options.maxCells ?? 60 : 0,
191      maxLevel: current ? 6 : 2,
192    });
193  });
194  const many = entries.length > 1;
195  return [
196    `<${STATE_TAG} notebooks="${entries.length}">`,
197    `Live state of the marimo notebook${many ? "s" : ""} open in the user's browser, added automatically by pi-marimo (not`,
198    ...(options.refresh === "prompt"
199      ? ["written by the user). Taken when the user sent their latest message and not updated during the turn;",
200        "earlier copies are removed. Check current values through marimo-pair before relying on them."]
201      : ["written by the user). It is replaced with a fresh copy on every request, so trust this over older reads."]),
202    ...(many ? ["The current notebook (the one used most recently) comes first and in full; the others are outlines",
203      "with only the cells that need attention."] : []),
204    "Cells are listed in notebook order under their markdown headings, by cell id. Inspect or change cells",
205    "through the marimo-pair skill, not by editing the .py file.",
206    ...sections.flatMap((lines) => ["", ...lines]),
207    `</${STATE_TAG}>`,
208  ].join("\n");
209}
210
hooks/core/touch.ts 49 lines
1// Which notebooks an agent's tool call works in, read from its marimo-pair
2// commands, so each agent session can keep the notebook it works in current
3// even while another session (or the user) works in a different one.
4//
5// marimo-pair runs `execute-code.sh --url URL [--file PATH | --session ID]` or
6// `marimo pair execute --url URL [--file FILE] [--session ID]`. A tool call may
7// hold several (one bash command, a codemode script), in any quoting.
8
9export interface PairTarget {
10  url?: string;
11  file?: string;
12  session?: string;
13}
14
15const CALL = /execute-code\.sh|marimo\s+pair\s+execute/g;
16
17function flag(segment: string, name: string): string | undefined {
18  const match = new RegExp(`--${name}(?:=|\\s+)(?:"([^"]+)"|'([^']+)'|([^\\s"'\\\\;|&)]+))`).exec(segment);
19  const value = match?.[1] ?? match?.[2] ?? match?.[3];
20  // A shell variable or substitution cannot be resolved here.
21  return value && !value.includes("$") ? value : undefined;
22}
23
24/** The notebooks the marimo-pair calls in a tool call's text target, in order. */
25export function pairTargets(raw: string): PairTarget[] {
26  // Tool input arrives as JSON too, with its quotes escaped.
27  const text = raw.replace(/\\"/g, '"');
28  const starts: number[] = [];
29  CALL.lastIndex = 0;
30  for (let m = CALL.exec(text); m; m = CALL.exec(text)) starts.push(m.index);
31  return starts.map((start, i) => {
32    const segment = text.slice(start, starts[i + 1] ?? text.length).split(/\n|\\n/)[0] ?? "";
33    const target: PairTarget = {};
34    const url = flag(segment, "url");
35    const file = flag(segment, "file");
36    const session = flag(segment, "session");
37    if (url) target.url = url;
38    if (file) target.file = file;
39    if (session) target.session = session;
40    return target;
41  });
42}
43
44/** One server, however it is written (localhost or 127.0.0.1, a trailing slash). */
45export function sameServer(a: string, b: string): boolean {
46  const norm = (u: string) => u.replace(/\/+$/, "").replace("://localhost", "://127.0.0.1").replace("://[::1]", "://127.0.0.1");
47  return norm(a) === norm(b);
48}
49
hooks/core/watcher.ts 348 lines
1// Keeps the live state of the notebooks you work in current. It finds them,
2// subscribes to each one's session as a kiosk consumer, follows them across
3// page reloads (which change the session id) and server restarts, and reports
4// every change. The followed notebooks are the pinned ones, or in auto mode
5// every one open under the cwd. The current one is the one this session's agent
6// worked in last (through marimo-pair), so two sessions in two notebooks each
7// keep their own; before the agent touches any, the one used most recently by
8// anyone (a running cell, then the latest cell run or edit).
9
10import { type Cancellable, type Io, normalize, within } from "./io.js";
11import { NotebookState } from "./notebook.js";
12import { findNotebooks, type NotebookSession } from "./registry.js";
13import type { Attachment } from "./render.js";
14import { streamSession } from "./sse.js";
15import { type PairTarget, sameServer } from "./touch.js";
16
17export type Mode =
18  | { kind: "auto" }
19  | { kind: "pinned"; paths: string[] }
20  | { kind: "off" };
21
22export type Connection = "idle" | "searching" | "connecting" | "connected" | "disconnected";
23
24export interface WatcherOptions {
25  io: Io;
26  cwd: string;
27  token?: string;
28  extraUrls?: string[];
29  pollMs?: number;
30  /** Most notebooks followed at once in auto mode. */
31  maxNotebooks?: number;
32  onChange: () => void;
33}
34
35type Target = NotebookSession & { real: string; shared: boolean };
36
37const consumerId = (): string => `pi-marimo-${Math.random().toString(36).slice(2, 10)}`;
38
39/** A followed notebook, as the hosts see it. */
40export interface Followed {
41  attachment: Attachment;
42  notebook: NotebookState;
43  connection: Connection;
44  current: boolean;
45  /** When this session's agent last worked in it (0: never). */
46  touchedAt: number;
47}
48
49/** Read a saved or configured mode, accepting the single-notebook form `{ kind: "pinned", path }`. */
50export function parseMode(raw: unknown): Mode | undefined {
51  const mode = raw as { kind?: unknown; path?: unknown; paths?: unknown } | undefined;
52  if (mode?.kind === "auto" || mode?.kind === "off") return { kind: mode.kind };
53  if (mode?.kind !== "pinned") return undefined;
54  const paths = Array.isArray(mode.paths) ? mode.paths.filter((p): p is string => typeof p === "string") : typeof mode.path === "string" ? [mode.path] : [];
55  return paths.length ? { kind: "pinned", paths } : undefined;
56}
57
58/** One notebook's subscription, reconnecting until stopped. */
59class Follower {
60  readonly notebook = new NotebookState();
61  connection: Connection = "connecting";
62  /** Whether browser edits reach us (false when connected by the browser's own session id). */
63  seesBrowserEdits = false;
64  /** When this session's agent last worked in the notebook (0: never). */
65  touchedAt = 0;
66  private stopped = false;
67  private current?: Cancellable<unknown>;
68  private sleep?: Cancellable<void>;
69  private readonly id = consumerId();
70  readonly loop: Promise<void>;
71
72  constructor(public target: Target, private readonly options: WatcherOptions, private readonly changed: () => void) {
73    this.loop = this.run();
74  }
75
76  get attachment(): Attachment {
77    return { url: this.target.url, sessionId: this.target.sessionId, path: this.target.path };
78  }
79
80  /** Point at a new session for the same file (a page reload) and reconnect. */
81  retarget(target: Target): void {
82    if (target.sessionId === this.target.sessionId && target.shared === this.target.shared) return;
83    this.target = target;
84    this.current?.cancel();
85    this.sleep?.cancel();
86  }
87
88  stop(): void {
89    this.stopped = true;
90    this.current?.cancel();
91    this.sleep?.cancel();
92  }
93
94  private set(connection: Connection): void {
95    if (connection === this.connection) return;
96    this.connection = connection;
97    this.changed();
98  }
99
100  private async run(): Promise<void> {
101    const pollMs = this.options.pollMs ?? 5000;
102    while (!this.stopped) {
103      const target = this.target;
104      this.seesBrowserEdits = !target.shared;
105      this.notebook.reset();
106      this.set("connecting");
107      const stream = streamSession({
108        io: this.options.io,
109        url: target.url,
110        // marimo names a consumer after the session id it connected with and
111        // does not echo a browser's own edits back to that id. Connecting
112        // under our own id (marimo then finds the session by file) receives
113        // them. That lookup takes the oldest session for the file, so when
114        // several hold it, connect by the exact id and miss browser edits.
115        sessionId: target.shared ? target.sessionId : this.id,
116        file: target.path,
117        token: this.options.token,
118        onMessage: (op, data) => {
119          const changed = this.notebook.apply(op, data);
120          if (op === "kernel-ready") this.set("connected");
121          if (changed) this.changed();
122        },
123      });
124      this.current = stream;
125      const result = await stream.done.catch(() => ({ kind: "http-error" as const }));
126      if (this.stopped) break;
127      this.set("disconnected");
128      if (target !== this.target) continue;
129      // A dropped stream reconnects quickly; a refused one waits for the next poll.
130      this.sleep = this.options.io.sleep(result.kind === "ended" ? 1000 : pollMs);
131      await this.sleep.done;
132    }
133  }
134}
135
136export class MarimoWatcher {
137  mode: Mode = { kind: "auto" };
138  private followers = new Map<string, Follower>();
139  private stopped = true;
140  private scanned = false;
141  private wake?: () => void;
142  private loop?: Promise<void>;
143  private realCwd?: string;
144  private readonly realPaths = new Map<string, string>();
145  private readonly empty = new NotebookState();
146
147  constructor(private readonly options: WatcherOptions) {}
148
149  /** Followers, the current one first, then by most recent use. */
150  private ranked(): Follower[] {
151    const rank = (x: Follower): number[] => [x.connection === "connected" ? 1 : 0, x.touchedAt, x.notebook.running() ? 1 : 0, x.notebook.lastActivity];
152    const cmp = (a: Follower, b: Follower): number => {
153      const [x, y] = [rank(a), rank(b)];
154      for (let i = 0; i < x.length; i++) if (x[i] !== y[i]) return y[i]! - x[i]!;
155      return 0;
156    };
157    return [...this.followers.values()].sort(cmp);
158  }
159
160  /** The current notebook: the one used most recently. */
161  private get active(): Follower | undefined {
162    return this.ranked()[0];
163  }
164
165  /** Every followed notebook, the current one first, then by most recent use. */
166  followed(): Followed[] {
167    return this.ranked().map((f, i) => ({ attachment: f.attachment, notebook: f.notebook, connection: f.connection, current: i === 0, touchedAt: f.touchedAt }));
168  }
169
170  get notebook(): NotebookState {
171    return this.active?.notebook ?? this.empty;
172  }
173
174  get attachment(): Attachment | undefined {
175    const active = this.active;
176    if (active) return active.attachment;
177    return this.mode.kind === "pinned" ? { url: "", sessionId: "", path: this.mode.paths[0]! } : undefined;
178  }
179
180  get connection(): Connection {
181    const active = this.active;
182    if (active) return active.connection;
183    return this.mode.kind === "off" ? "idle" : "searching";
184  }
185
186  get seesBrowserEdits(): boolean {
187    return this.active?.seesBrowserEdits ?? false;
188  }
189
190  /**
191   * The one followed notebook a marimo-pair call targets, if it is unambiguous: by session id, by
192   * file (as marimo-pair takes it: absolute, or relative to the server's directory), or by server
193   * alone when only one followed notebook is on it.
194   */
195  private match(t: PairTarget) {
196    let matches = [...this.followers.values()];
197    if (t.url) matches = matches.filter((f) => sameServer(f.target.url, t.url!));
198    if (t.session) matches = matches.filter((f) => f.target.sessionId === t.session);
199    else if (t.file) {
200      const file = t.file.replace(/^\.\//, "");
201      matches = matches.filter((f) => f.target.path === file || f.target.real === file || f.target.path.endsWith(`/${file}`) || f.target.real.endsWith(`/${file}`));
202    } else if (!t.url) matches = [];
203    return matches.length === 1 ? matches[0] : undefined;
204  }
205
206  /** The paths of the followed notebooks these marimo-pair calls target. */
207  targeted(targets: PairTarget[]): string[] {
208    return targets.map((t) => this.match(t)?.attachment.path).filter((p): p is string => p !== undefined);
209  }
210
211  /**
212   * Note that this session's agent works in these notebooks (from its marimo-pair calls), making
213   * the last one current for this session. Returns whether any followed notebook matched.
214   */
215  touch(targets: PairTarget[], now = Date.now()): boolean {
216    let touched = false;
217    targets.forEach((t, i) => {
218      const match = this.match(t);
219      if (!match) return;
220      // Later calls in one tool call win.
221      match.touchedAt = now + i;
222      touched = true;
223    });
224    if (touched) this.options.onChange();
225    return touched;
226  }
227
228  /** The other notebooks followed besides the current one, most recently used first. */
229  others(): Attachment[] {
230    return this.ranked().slice(1).map((f) => f.attachment);
231  }
232
233  start(): void {
234    if (!this.stopped) return;
235    this.stopped = false;
236    this.loop = this.run();
237  }
238
239  /** Stop, waiting at most a second for streams to close (a host may only end one at its next chunk). */
240  async stop(): Promise<void> {
241    this.stopped = true;
242    this.wake?.();
243    const loops = [...this.followers.values()].map((f) => { f.stop(); return f.loop; });
244    this.followers.clear();
245    await Promise.race([Promise.all([this.loop, ...loops]).catch(() => undefined), this.options.io.sleep(1000).done]);
246  }
247
248  setMode(mode: Mode): void {
249    this.mode = mode;
250    this.scanned = false;
251    this.wake?.();
252  }
253
254  /** Wait (up to ms) for the first scan and any connection in progress, so a first request has the state. */
255  async settle(ms: number): Promise<void> {
256    const busy = () => !this.scanned || [...this.followers.values()].some((f) => f.connection === "connecting");
257    for (let waited = 0; !this.stopped && busy() && waited < ms; waited += 50) {
258      await this.options.io.sleep(50).done;
259    }
260  }
261
262  /** Rescan now instead of at the next poll. */
263  refresh(): void {
264    this.wake?.();
265  }
266
267  async list(): Promise<NotebookSession[]> {
268    return findNotebooks(this.options.io, { token: this.options.token, extraUrls: this.options.extraUrls });
269  }
270
271  private async real(path: string): Promise<string> {
272    const absolute = normalize(path, this.options.cwd);
273    let real = this.realPaths.get(absolute);
274    if (real === undefined) {
275      real = normalize(await this.options.io.realpath(absolute));
276      this.realPaths.set(absolute, real);
277    }
278    return real;
279  }
280
281  /** The notebooks to follow now, keyed by server and file. */
282  private async targets(): Promise<Map<string, Target>> {
283    const mode = this.mode;
284    const out = new Map<string, Target>();
285    if (mode.kind === "off") return out;
286    const sessions = await this.list();
287    const real = new Map<NotebookSession, string>();
288    for (const s of sessions) real.set(s, await this.real(s.path));
289    // Several sessions can hold one file (a closed tab's session lingers);
290    // marimo lists them oldest first, so the newest wins.
291    const add = (s: NotebookSession): void => {
292      const path = real.get(s)!;
293      const shared = sessions.filter((o) => o.url === s.url && real.get(o) === path).length > 1;
294      out.set(`${s.url} ${path}`, { ...s, real: path, shared });
295    };
296    if (mode.kind === "pinned") {
297      for (const pinned of mode.paths) {
298        const path = await this.real(pinned);
299        const chosen = sessions.filter((s) => real.get(s) === path).pop();
300        if (chosen) add(chosen);
301      }
302      return out;
303    }
304    this.realCwd ??= await this.real(this.options.cwd);
305    const cwd = this.realCwd;
306    // Under the cwd, but not in a hidden directory there (.worktrees, .claude/worktrees: other checkouts).
307    const local = sessions.filter((s) => {
308      const path = real.get(s)!;
309      return within(path, cwd) && !path.slice(cwd.length).split("/").slice(0, -1).some((part) => part.startsWith("."));
310    });
311    for (const s of local) add(s);
312    const max = this.options.maxNotebooks ?? 8;
313    return new Map([...out].slice(0, max));
314  }
315
316  private reconcile(targets: Map<string, Target>): void {
317    let changed = false;
318    for (const [key, follower] of this.followers) {
319      if (targets.has(key)) continue;
320      follower.stop();
321      this.followers.delete(key);
322      changed = true;
323    }
324    for (const [key, target] of targets) {
325      const follower = this.followers.get(key);
326      if (follower) follower.retarget(target);
327      else {
328        this.followers.set(key, new Follower(target, this.options, () => this.options.onChange()));
329        changed = true;
330      }
331    }
332    if (changed) this.options.onChange();
333  }
334
335  private async run(): Promise<void> {
336    const pollMs = this.options.pollMs ?? 5000;
337    while (!this.stopped) {
338      const targets = await this.targets().catch(() => undefined);
339      if (this.stopped) break;
340      if (targets) this.reconcile(targets);
341      if (!this.scanned) { this.scanned = true; this.options.onChange(); }
342      const sleep = this.options.io.sleep(this.mode.kind === "off" ? 60_000 : pollMs);
343      this.wake = sleep.cancel;
344      await sleep.done;
345    }
346  }
347}
348
hooks/core/notebook.ts 335 lines
1// A notebook's live state, rebuilt from the messages marimo streams to its
2// frontends: the cell document (kernel-ready, notebook-document-transaction),
3// each cell's run state and errors (cell-op), and the dataflow graph
4// (variables). Only what an agent needs is kept; outputs are dropped.
5
6import { type Heading, headingsFromCode, isMarkdownCell, sameCode } from "./markdown.js";
7
8export type EditSource = "frontend" | "code-mode" | "kernel" | string;
9
10export interface Cell {
11  id: string;
12  code: string;
13  name: string;
14  disabled: boolean;
15  /** "idle" | "queued" | "running" | "disabled-transitively" */
16  status: string;
17  /** When the current run started (ms since epoch), while running. */
18  runningSince?: number;
19  /** Inputs changed and the cell has not rerun (lazy runtime). */
20  stale: boolean;
21  /** One-line description of the error the cell's last run raised. */
22  error?: string;
23  /** The cell did not run because an ancestor failed. */
24  blocked?: string;
25  /** Code the kernel last ran for this cell, when known. */
26  lastRunCode?: string;
27  /** Who last changed the code, and at which change number. */
28  editedBy?: EditSource;
29  editSeq: number;
30}
31
32export interface RunningCell {
33  cell: Cell;
34  section: string[];
35  since?: number;
36}
37
38const record = (value: unknown): Record<string, unknown> =>
39  value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
40const strings = (value: unknown): string[] => Array.isArray(value) ? value.filter((v): v is string => typeof v === "string") : [];
41const isInternal = (id: string): boolean => id.startsWith("__");
42
43export function describeError(output: Record<string, unknown>): { error?: string; blocked?: string } {
44  const errors = Array.isArray(output.data) ? output.data.map(record) : [];
45  const parts: string[] = [];
46  let blocked: string | undefined;
47  for (const err of errors) {
48    const type = typeof err.type === "string" ? err.type : "error";
49    const msg = typeof err.msg === "string" ? err.msg.trim().split("\n")[0] : "";
50    if (type === "ancestor-prevented" || type === "ancestor-stopped") {
51      blocked = msg || "an ancestor cell failed";
52      continue;
53    }
54    if (type === "exception") {
55      const name = typeof err.exception_type === "string" ? err.exception_type : "Exception";
56      parts.push(msg ? `${name}: ${msg}` : name);
57    } else if (type === "multiple-defs") {
58      const name = typeof err.name === "string" ? err.name : "?";
59      parts.push(`multiple definitions of ${name}`);
60    } else if (type === "interruption") {
61      parts.push("interrupted");
62    } else {
63      parts.push(msg ? `${type}: ${msg}` : type);
64    }
65  }
66  const error = parts.length ? truncate(parts.join("; "), 200) : undefined;
67  return { error, blocked: error ? undefined : blocked };
68}
69
70export function truncate(text: string, max: number): string {
71  return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
72}
73
74export class NotebookState {
75  order: string[] = [];
76  cells = new Map<string, Cell>();
77  defs = new Map<string, string[]>();
78  ready = false;
79  /** Increments on every document change. */
80  seq = 0;
81  /**
82   * When the notebook was last used (ms since epoch): the latest cell run the
83   * kernel reports (replayed on connect, so a notebook's history counts), or an
84   * edit seen live.
85   */
86  lastActivity = 0;
87  private readyAt = 0;
88  private headingCache = new Map<string, Heading[]>();
89
90  reset(): void {
91    this.order = [];
92    this.cells.clear();
93    this.defs.clear();
94    this.ready = false;
95    this.lastActivity = 0;
96    this.readyAt = 0;
97    this.headingCache.clear();
98  }
99
100  private ensure(id: string): Cell {
101    let cell = this.cells.get(id);
102    if (!cell) {
103      cell = { id, code: "", name: "_", disabled: false, status: "idle", stale: false, editSeq: 0 };
104      this.cells.set(id, cell);
105    }
106    return cell;
107  }
108
109  headings(cell: Cell): Heading[] {
110    let headings = this.headingCache.get(cell.code);
111    if (!headings) {
112      headings = headingsFromCode(cell.code);
113      if (this.headingCache.size > 2000) this.headingCache.clear();
114      this.headingCache.set(cell.code, headings);
115    }
116    return headings;
117  }
118
119  isMarkdown(cell: Cell): boolean {
120    return isMarkdownCell(cell.code);
121  }
122
123  /** Apply one streamed message. Returns true when agent-visible state changed. */
124  apply(op: string, raw: unknown): boolean {
125    const data = record(raw);
126    switch (op) {
127      case "kernel-ready":
128        return this.kernelReady(data);
129      case "notebook-document-transaction":
130        return this.transaction(record(data.transaction));
131      case "cell-op":
132        return this.cellOp(data);
133      case "variables":
134        return this.variables(data);
135      default:
136        return false;
137    }
138  }
139
140  private kernelReady(data: Record<string, unknown>): boolean {
141    this.reset();
142    const ids = strings(data.cell_ids);
143    const codes = strings(data.codes);
144    const names = strings(data.names);
145    const configs = Array.isArray(data.configs) ? data.configs.map(record) : [];
146    const lastRun = record(data.last_executed_code);
147    ids.forEach((id, i) => {
148      const cell = this.ensure(id);
149      cell.code = codes[i] ?? "";
150      cell.name = names[i] || "_";
151      cell.disabled = configs[i]?.disabled === true;
152      if (typeof lastRun[id] === "string") cell.lastRunCode = lastRun[id] as string;
153    });
154    this.order = ids;
155    this.ready = true;
156    this.readyAt = Date.now();
157    return true;
158  }
159
160  private place(id: string, before: unknown, after: unknown): void {
161    this.order = this.order.filter((other) => other !== id);
162    if (typeof before === "string" && this.order.includes(before)) {
163      this.order.splice(this.order.indexOf(before), 0, id);
164    } else if (typeof after === "string" && this.order.includes(after)) {
165      this.order.splice(this.order.indexOf(after) + 1, 0, id);
166    } else {
167      this.order.push(id);
168    }
169  }
170
171  private transaction(tx: Record<string, unknown>): boolean {
172    const source = typeof tx.source === "string" ? tx.source : "unknown";
173    const changes = Array.isArray(tx.changes) ? tx.changes.map(record) : [];
174    let changed = false;
175    for (const change of changes) {
176      const id = typeof change.cellId === "string" ? change.cellId : "";
177      switch (change.type) {
178        case "create-cell": {
179          const cell = this.ensure(id);
180          cell.code = typeof change.code === "string" ? change.code : "";
181          cell.name = typeof change.name === "string" && change.name ? change.name : "_";
182          cell.disabled = record(change.config).disabled === true;
183          cell.editedBy = source;
184          cell.editSeq = ++this.seq;
185          this.place(id, change.before, change.after);
186          changed = true;
187          break;
188        }
189        case "delete-cell":
190          this.cells.delete(id);
191          this.order = this.order.filter((other) => other !== id);
192          this.defs.delete(id);
193          this.seq++;
194          changed = true;
195          break;
196        case "move-cell":
197          this.place(id, change.before, change.after);
198          this.seq++;
199          changed = true;
200          break;
201        case "reorder-cells": {
202          const ids = strings(change.cellIds).filter((cid) => this.cells.has(cid));
203          const rest = this.order.filter((cid) => !ids.includes(cid));
204          const next = [...ids, ...rest];
205          if (next.join() !== this.order.join()) { this.order = next; this.seq++; changed = true; }
206          break;
207        }
208        case "set-code": {
209          const cell = this.ensure(id);
210          const code = typeof change.code === "string" ? change.code : "";
211          // The browser respelling a markdown cell is not an edit.
212          if (cell.code !== code && sameCode(cell.code, code)) cell.code = code;
213          else if (cell.code !== code) {
214            cell.code = code;
215            cell.editedBy = source;
216            cell.editSeq = ++this.seq;
217            changed = true;
218          }
219          break;
220        }
221        case "set-name": {
222          const cell = this.ensure(id);
223          cell.name = typeof change.name === "string" && change.name ? change.name : "_";
224          changed = true;
225          break;
226        }
227        case "set-config": {
228          const cell = this.ensure(id);
229          const disabled = change.disabled === true;
230          if (cell.disabled !== disabled) { cell.disabled = disabled; changed = true; }
231          break;
232        }
233      }
234    }
235    // An edit counts as use when it arrives live, not in the replay on connect.
236    if (source !== "kernel" && changed && this.readyAt && Date.now() - this.readyAt > 1500) this.lastActivity = Date.now();
237    return changed;
238  }
239
240  private cellOp(data: Record<string, unknown>): boolean {
241    const id = typeof data.cell_id === "string" ? data.cell_id : "";
242    if (!id || isInternal(id) || !this.cells.has(id)) return false;
243    const cell = this.ensure(id);
244    let changed = false;
245    if (typeof data.status === "string" && typeof data.timestamp === "number") {
246      this.lastActivity = Math.max(this.lastActivity, data.timestamp * 1000);
247    }
248    if (typeof data.status === "string" && data.status !== cell.status) {
249      cell.status = data.status;
250      if (data.status === "queued") {
251        // The previous run's error is about to be replaced.
252        cell.error = undefined;
253        cell.blocked = undefined;
254      }
255      if (data.status === "running") {
256        const at = typeof data.timestamp === "number" ? data.timestamp * 1000 : Date.now();
257        cell.runningSince = at;
258        cell.lastRunCode = cell.code;
259        cell.error = undefined;
260        cell.blocked = undefined;
261        cell.stale = false;
262      } else {
263        cell.runningSince = undefined;
264      }
265      changed = true;
266    }
267    if (typeof data.stale_inputs === "boolean" && data.stale_inputs !== cell.stale) {
268      cell.stale = data.stale_inputs;
269      changed = true;
270    }
271    if (data.output !== null && typeof data.output === "object") {
272      const output = record(data.output);
273      const { error, blocked } = output.channel === "marimo-error" ? describeError(output) : {};
274      if (error !== cell.error || blocked !== cell.blocked) {
275        cell.error = error;
276        cell.blocked = blocked;
277        changed = true;
278      }
279    }
280    return changed;
281  }
282
283  private variables(data: Record<string, unknown>): boolean {
284    const vars = Array.isArray(data.variables) ? data.variables.map(record) : [];
285    const defs = new Map<string, string[]>();
286    for (const v of vars) {
287      if (typeof v.name !== "string") continue;
288      for (const id of strings(v.declared_by)) {
289        if (!defs.has(id)) defs.set(id, []);
290        defs.get(id)!.push(v.name);
291      }
292    }
293    for (const names of defs.values()) names.sort();
294    const before = JSON.stringify([...this.defs.entries()].sort());
295    this.defs = defs;
296    return before !== JSON.stringify([...defs.entries()].sort());
297  }
298
299  /** Cells in notebook order. */
300  ordered(): Cell[] {
301    return this.order.map((id) => this.cells.get(id)).filter((cell): cell is Cell => cell !== undefined);
302  }
303
304  /** The heading path ("Section", "Subsection") a cell sits under, including its own headings. */
305  sectionPath(cellId: string): string[] {
306    const stack: Heading[] = [];
307    for (const cell of this.ordered()) {
308      for (const heading of this.headings(cell)) {
309        while (stack.length && stack[stack.length - 1]!.level >= heading.level) stack.pop();
310        stack.push(heading);
311      }
312      if (cell.id === cellId) break;
313    }
314    return stack.map((h) => h.text);
315  }
316
317  running(): RunningCell | undefined {
318    const cell = this.ordered().find((c) => c.status === "running");
319    return cell ? { cell, section: this.sectionPath(cell.id), since: cell.runningSince } : undefined;
320  }
321
322  queued(): number {
323    return this.ordered().filter((c) => c.status === "queued").length;
324  }
325
326  errors(): Cell[] {
327    return this.ordered().filter((c) => c.error);
328  }
329
330  /** Code changed since the kernel last ran it (only known once a run was seen). */
331  edited(cell: Cell): boolean {
332    return cell.lastRunCode !== undefined && !sameCode(cell.lastRunCode, cell.code);
333  }
334}
335
hooks/core/registry.ts 53 lines
1// Find running marimo servers and the notebooks open on them.
2//
3// Servers started with --no-token register a JSON file under
4// $XDG_STATE_HOME/marimo/servers (marimo/_server/server_registry.py). A
5// token-protected server is reached with MARIMO_TOKEN.
6
7import type { Io } from "./io.js";
8
9export interface NotebookSession {
10  url: string;
11  sessionId: string;
12  path: string;
13}
14
15export function authHeaders(token?: string): Record<string, string> {
16  return token ? { Authorization: `Bearer ${token}` } : {};
17}
18
19function hostFor(host: unknown): string {
20  if (typeof host !== "string" || host === "" || host === "0.0.0.0") return "127.0.0.1";
21  if (host === "::") return "[::1]";
22  return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
23}
24
25/** Base URLs of the registered servers. */
26export async function serverUrls(io: Io): Promise<string[]> {
27  const urls: string[] = [];
28  for (const raw of await io.registryEntries()) {
29    const entry = (raw ?? {}) as Record<string, unknown>;
30    if (typeof entry.port !== "number") continue;
31    const base = typeof entry.base_url === "string" ? entry.base_url.replace(/\/$/, "") : "";
32    urls.push(`http://${hostFor(entry.host)}:${entry.port}${base}`);
33  }
34  return urls;
35}
36
37export async function listSessions(io: Io, url: string, token?: string): Promise<NotebookSession[]> {
38  const body = await io.getJson(`${url}/api/sessions`, authHeaders(token), 2000);
39  if (body === null || typeof body !== "object") return [];
40  return Object.entries(body as Record<string, Record<string, unknown> | null>).flatMap(([sessionId, info]) => {
41    const path = typeof info?.path === "string" ? info.path : typeof info?.filename === "string" ? info.filename : "";
42    return path ? [{ url, sessionId, path }] : [];
43  });
44}
45
46/** Every notebook session open on every registered server, plus any extra server URLs. */
47export async function findNotebooks(io: Io, options: { token?: string; extraUrls?: string[] } = {}): Promise<NotebookSession[]> {
48  const urls = new Set(await serverUrls(io));
49  for (const url of options.extraUrls ?? []) urls.add(url.replace(/\/$/, ""));
50  const lists = await Promise.all([...urls].map((url) => listSessions(io, url, options.token)));
51  return lists.flat();
52}
53
hooks/core/sse.ts 98 lines
1// A read-only (kiosk) subscription to a marimo session's message stream.
2//
3// marimo serves the same kernel messages over /ws and /sse. A kiosk consumer
4// is never the session's main consumer: it cannot edit or run code and does
5// not take the notebook over from the browser. Connecting replays the
6// session's current state (kernel-ready, then every cell's last messages), so
7// a fresh NotebookState is complete after the replay.
8
9import type { Cancellable, Io } from "./io.js";
10import { authHeaders } from "./registry.js";
11
12export interface SseEvent {
13  event?: string;
14  data: string;
15}
16
17/** Incremental parser for a text/event-stream body. */
18export class SseParser {
19  private buffer = "";
20
21  push(chunk: string): SseEvent[] {
22    this.buffer += chunk.replace(/\r\n?/g, "\n");
23    const events: SseEvent[] = [];
24    let end = this.buffer.indexOf("\n\n");
25    while (end >= 0) {
26      const block = this.buffer.slice(0, end);
27      this.buffer = this.buffer.slice(end + 2);
28      let event: string | undefined;
29      const data: string[] = [];
30      for (const line of block.split("\n")) {
31        if (!line || line.startsWith(":")) continue;
32        const colon = line.indexOf(":");
33        const field = colon < 0 ? line : line.slice(0, colon);
34        const value = colon < 0 ? "" : line.slice(colon + 1).replace(/^ /, "");
35        if (field === "event") event = value;
36        else if (field === "data") data.push(value);
37      }
38      if (data.length) events.push({ event, data: data.join("\n") });
39      end = this.buffer.indexOf("\n\n");
40    }
41    return events;
42  }
43}
44
45export interface StreamResult {
46  /** "closed": the server ended the stream with a close event. "ended": the stream dropped. */
47  kind: "closed" | "ended" | "http-error";
48  code?: number;
49  reason?: string;
50}
51
52export interface StreamOptions {
53  io: Io;
54  url: string;
55  sessionId: string;
56  file?: string;
57  token?: string;
58  onMessage: (op: string, data: unknown) => void;
59}
60
61export function streamSession(options: StreamOptions): Cancellable<StreamResult> {
62  const query: Record<string, string> = { session_id: options.sessionId, kiosk: "true" };
63  if (options.file) query.file = options.file;
64  if (options.token) query.access_token = options.token;
65  const params = Object.entries(query).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("&");
66  const parser = new SseParser();
67  let closed: StreamResult | undefined;
68  const stream = options.io.stream(
69    `${options.url}/sse?${params}`,
70    { Accept: "text/event-stream", ...authHeaders(options.token) },
71    (text) => {
72      if (closed) return;
73      for (const event of parser.push(text)) {
74        if (event.event === "close") {
75          try {
76            const body = JSON.parse(event.data) as { code?: number; reason?: string };
77            closed = { kind: "closed", code: body.code, reason: body.reason };
78          } catch {
79            closed = { kind: "closed" };
80          }
81          stream.cancel();
82          return;
83        }
84        if (event.event && event.event !== "message") continue;
85        let message: { op?: unknown; data?: unknown };
86        try {
87          message = JSON.parse(event.data);
88        } catch {
89          continue;
90        }
91        if (typeof message.op === "string") options.onMessage(message.op, message.data);
92      }
93    },
94  );
95  const done = stream.done.then((end): StreamResult => closed ?? (end.ok ? { kind: "ended" } : { kind: "http-error", code: end.status }));
96  return { done, cancel: stream.cancel };
97}
98
hooks/core/markdown.ts 110 lines
1// Headings a cell renders through mo.md(...), read from its source code.
2//
3// marimo has no "markdown cell" type: a markdown cell is a Python cell whose
4// body is `mo.md("""...""")`. Headings are taken from the string literals passed
5// to mo.md, never from Python comments, and fenced code blocks are skipped.
6
7export interface Heading {
8  level: number;
9  text: string;
10}
11
12const CALL = /\bmo\s*\.\s*md\s*\(\s*([rRfFuUbB]{0,2})("""|'''|"|')/g;
13
14/** The string literals passed to mo.md(...) in a cell's code. */
15export function mdLiterals(code: string): string[] {
16  const out: string[] = [];
17  CALL.lastIndex = 0;
18  for (let match = CALL.exec(code); match; match = CALL.exec(code)) {
19    const prefix = (match[1] ?? "").toLowerCase();
20    const quote = match[2] ?? '"';
21    const start = match.index + match[0].length;
22    const raw = prefix.includes("r");
23    let i = start;
24    let end = -1;
25    while (i < code.length) {
26      const ch = code[i];
27      if (ch === "\\") { i += 2; continue; }
28      if (quote.length === 1 && ch === "\n") break;
29      if (code.startsWith(quote, i)) { end = i; break; }
30      i++;
31    }
32    if (end < 0) continue;
33    const body = code.slice(start, end);
34    out.push(raw ? body : body.replace(/\\(["'\\])/g, "$1").replace(/\\n/g, "\n"));
35    CALL.lastIndex = end + quote.length;
36  }
37  return out;
38}
39
40function dedent(text: string): string {
41  const lines = text.split("\n");
42  let min = Number.POSITIVE_INFINITY;
43  for (const line of lines.slice(1)) {
44    if (!line.trim()) continue;
45    min = Math.min(min, line.length - line.trimStart().length);
46  }
47  if (!Number.isFinite(min) || min === 0) return text;
48  return [lines[0], ...lines.slice(1).map((line) => line.slice(Math.min(min, line.length - line.trimStart().length)))].join("\n");
49}
50
51/** Strip inline markdown so a heading reads as plain text. */
52export function plainHeading(text: string): string {
53  return text
54    .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
55    .replace(/<[^>]+>/g, "")
56    .replace(/(\*\*|__|\*|_|`|~~)(.+?)\1/g, "$2")
57    .replace(/\s+#+\s*$/, "")
58    .replace(/\s+/g, " ")
59    .trim();
60}
61
62export function headingsFromMarkdown(markdown: string): Heading[] {
63  const headings: Heading[] = [];
64  let fence: string | null = null;
65  for (const line of dedent(markdown).split("\n")) {
66    const fenceMatch = /^\s{0,3}(`{3,}|~{3,})/.exec(line);
67    if (fenceMatch) {
68      const marker = (fenceMatch[1] ?? "`").charAt(0);
69      if (fence === null) fence = marker;
70      else if (marker === fence) fence = null;
71      continue;
72    }
73    if (fence !== null) continue;
74    const heading = /^\s{0,3}(#{1,6})\s+(.+)$/.exec(line);
75    if (!heading) continue;
76    const text = plainHeading(heading[2] ?? "");
77    if (text) headings.push({ level: (heading[1] ?? "#").length, text });
78  }
79  return headings;
80}
81
82export function headingsFromCode(code: string): Heading[] {
83  return mdLiterals(code).flatMap(headingsFromMarkdown);
84}
85
86/** True when the cell does nothing but render markdown. */
87export function isMarkdownCell(code: string): boolean {
88  const trimmed = code.trim();
89  if (!trimmed.startsWith("mo.md(")) return false;
90  const literals = mdLiterals(trimmed);
91  return literals.length === 1 && /\)\s*$/.test(trimmed);
92}
93
94/** The markdown a markdown cell renders, whatever quoting and indentation spell it; undefined for other cells. */
95export function markdownText(code: string): string | undefined {
96  if (!isMarkdownCell(code)) return undefined;
97  return dedent(mdLiterals(code.trim())[0] ?? "").split("\n").map((line) => line.trimEnd()).join("\n").trim();
98}
99
100/**
101 * The same code, or two spellings of the same markdown. marimo's editor rewrites a markdown cell
102 * into its own form (`mo.md("""` on its own line, dedented) as soon as a browser shows it, and
103 * posts that as a browser edit, though no one typed anything.
104 */
105export function sameCode(a: string, b: string): boolean {
106  if (a === b) return true;
107  const markdown = markdownText(a);
108  return markdown !== undefined && markdown === markdownText(b);
109}
110