SLOPSHOPPER

nushell-cards

Draw each Nushell evaluate call as a card titled by its comment, and stream the logs of job-log jobs into it and into their Monitors.

rowsguardprocesstimer
A shopper browsing a rack in a slop shop
Source 2 files
hooks/register.tsx 415 lines
1import { read, update } from "claude-code";
2import type { EngineInterface, Register } from "claude-code";
3
4import type { NushellJob } from "../types";
5
6// The Nushell MCP server's evaluate tool, under whichever plugin ships it.
7const EVALUATE = /^mcp__.*nushell__evaluate$/;
8// Source lines a card shows before folding the rest behind a button.
9const FOLDED_LINES = 8;
10// Log lines a job's card shows.
11const TAIL_LINES = 14;
12// A log at most this size is read whole; a larger one is tailed.
13const READ_LIMIT = 1024 * 1024;
14const POLL_MS = 1000;
15// Stop following a job that has run this long without an outcome.
16const FOLLOW_LIMIT_MS = 12 * 60 * 60 * 1000;
17// Logs kept in the follow list, oldest dropped first.
18const MAX_LOGS = 50;
19
20const jobs = { plugin: "nushell-cards", key: "jobs" } as const;
21const logs = { plugin: "nushell-cards", key: "logs" } as const;
22const expanded = { plugin: "nushell-cards", key: "expanded" } as const;
23
24type Dollar = EngineInterface;
25
26// The text of a tool result, whichever shape it arrives in: a string, MCP
27// content blocks, or a record holding them.
28export function resultText(output: unknown): string {
29  if (typeof output === "string") return output;
30  if (Array.isArray(output)) {
31    return output
32      .map((block) =>
33        block !== null &&
34        typeof block === "object" &&
35        typeof block.text === "string"
36          ? block.text
37          : "",
38      )
39      .join("\n");
40  }
41  if (output !== null && typeof output === "object") {
42    const record = output as { content?: unknown; text?: unknown };
43    if (Array.isArray(record.content)) return resultText(record.content);
44    if (typeof record.text === "string") return record.text;
45  }
46  return "";
47}
48
49// The log of the `{job, log}` record `job-log` returns, from an evaluate result.
50export function jobLog(output: unknown): string | undefined {
51  try {
52    const reply = JSON.parse(resultText(output));
53    const result = reply?.output;
54    const isJob =
55      result !== null && typeof result === "object" && "job" in result;
56    return isJob && typeof result.log === "string" && result.log.startsWith("/")
57      ? result.log
58      : undefined;
59  } catch {
60    return undefined;
61  }
62}
63
64// The card's title is the `# comment` line the call opens with, as the
65// global instructions ask every evaluation to start.
66export function splitSource(source: string): { title: string; body: string[] } {
67  const lines = source.replace(/\s+$/, "").split("\n");
68  const first = lines[0]?.trim() ?? "";
69  const hasTitle = first.startsWith("#");
70  const body = hasTitle ? lines.slice(1) : lines;
71  while (body.length > 0 && body[0]?.trim() === "") body.shift();
72  return { title: hasTitle ? first.replace(/^#+\s*/, "") : "nushell", body };
73}
74
75// The log a `watch-job LOG` Monitor follows: the literal path, or, for a
76// path built in Nushell (`$"($S)/build.log"`), the followed log with that
77// name, or for a bare variable, the newest followed log.
78export function monitoredLog(
79  command: unknown,
80  known: readonly string[],
81): string | undefined {
82  if (typeof command !== "string") return undefined;
83  const match = command.match(
84    /\bwatch-job\s+(?:--?[\w-]+(?:\s+(?!['"$\/~])\S+)?\s+)*(\S+)/,
85  );
86  if (match?.[1] === undefined) return undefined;
87  const token = match[1].replace(/^\$?(["'])(.*)\1$/, "$2");
88  if (token.startsWith("/")) return token;
89  const name = token.match(/\/([^/()$]+)$/)?.[1];
90  const newest = [...known].reverse();
91  if (name !== undefined) return newest.find((log) => log.endsWith(`/${name}`));
92  return /^\$\w+$/.test(token) ? newest[0] : undefined;
93}
94
95// One terminal line as a reader sees it: the last carriage-return overwrite,
96// without colours, OSC reports, or other escape sequences.
97export function cleanLine(line: string): string {
98  return (line.split("\r").pop() ?? "")
99    .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "")
100    .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
101    .replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "");
102}
103
104export function lastLines(text: string, count: number): string[] {
105  return text
106    .split("\n")
107    .map(cleanLine)
108    .filter((line) => line.trim() !== "")
109    .slice(-count);
110}
111
112// The latest OSC 7501 report among `pst report`'s JSON lines.
113export function latestPhase(text: string): string | undefined {
114  const reports = text
115    .split("\n")
116    .map((line) => {
117      try {
118        return JSON.parse(line);
119      } catch {
120        return undefined;
121      }
122    })
123    .filter(
124      (report) => report?.state !== undefined && report.state !== "clear",
125    );
126  const last = reports.at(-1);
127  if (last === undefined) return undefined;
128  return [last.state, last.msg]
129    .filter((part) => typeof part === "string" && part !== "")
130    .join(" · ");
131}
132
133// `LOG.done.nuon`'s `{exit_code, error?}`, read without a NUON parser.
134export function parseOutcome(
135  text: string,
136): { exitCode: number; error?: string } | undefined {
137  const code = text.match(/exit_code:\s*(-?\d+)/);
138  if (code === null) return undefined;
139  const raw = text
140    .match(/\berror:\s*("(?:[^"\\]|\\.)*"|'[^']*'|[^,}]+)/)?.[1]
141    ?.trim();
142  let error: string | undefined;
143  if (raw !== undefined) {
144    try {
145      error = raw.startsWith('"')
146        ? JSON.parse(raw)
147        : raw.replace(/^'(.*)'$/, "$1");
148    } catch {
149      error = raw;
150    }
151  }
152  return error === undefined
153    ? { exitCode: Number(code[1]) }
154    : { exitCode: Number(code[1]), error };
155}
156
157async function readText($: Dollar, path: string): Promise<string | undefined> {
158  const stat = await $.fs.stat(path).catch(() => undefined);
159  if (stat === undefined || stat.kind !== "file") return undefined;
160  if (stat.size <= READ_LIMIT) return $.fs.read(path).catch(() => undefined);
161  const tail = await $.process
162    .run(["tail", "-n", String(TAIL_LINES * 4), path])
163    .catch(() => undefined);
164  return tail?.stdout;
165}
166
167// The pid of the Nushell that runs the job: `job-log`'s `LOG.job.nuon`, or a
168// ci MCP job's `meta.json` beside its `log`.
169async function ownerPid($: Dollar, log: string): Promise<number | undefined> {
170  const record = await readText($, `${log}.job.nuon`);
171  const fromRecord = record?.match(/\bpid:\s*(\d+)/)?.[1];
172  if (fromRecord !== undefined) return Number(fromRecord);
173  if (!log.endsWith("/log")) return undefined;
174  const meta = await readText($, `${log.slice(0, -"log".length)}meta.json`);
175  try {
176    const pid = meta === undefined ? undefined : JSON.parse(meta).pid;
177    return typeof pid === "number" ? pid : undefined;
178  } catch {
179    return undefined;
180  }
181}
182
183async function readOutcome($: Dollar, log: string) {
184  const done = await readText($, `${log}.done.nuon`);
185  if (done !== undefined) return parseOutcome(done);
186  if (!log.endsWith("/log")) return undefined;
187  const exit = await readText($, `${log.slice(0, -"log".length)}exit`);
188  const code = exit?.trim();
189  return code !== undefined && /^-?\d+$/.test(code)
190    ? { exitCode: Number(code) }
191    : undefined;
192}
193
194// Read one job's files and store what changed. Check the owner before the
195// outcome, so a job that finishes in between reads as finished, not lost.
196async function poll($: Dollar, log: string, now: number): Promise<void> {
197  const ref = { ...jobs, id: log };
198  const { value: previous } = await $.state.get(ref);
199  const startedAt = previous?.startedAt ?? now;
200  const pid = await ownerPid($, log);
201  const isAlive = pid === undefined || (await $.fs.exists(`/proc/${pid}`));
202  const outcome = await readOutcome($, log);
203  const text = await readText($, log);
204  const phase = latestPhase((await readText($, `${log}.status.jsonl`)) ?? "");
205  const isLost = outcome === undefined && !isAlive;
206  const isStale = now - startedAt > FOLLOW_LIMIT_MS;
207  const job: NushellJob = {
208    log,
209    tail: lastLines(text ?? "", TAIL_LINES),
210    startedAt,
211    isDone: outcome !== undefined || isLost || isStale,
212    ...(phase !== undefined && phase !== "" && { phase }),
213    ...(outcome !== undefined && { exitCode: outcome.exitCode }),
214    ...(outcome?.error !== undefined && { error: outcome.error }),
215    ...(isLost && { isLost }),
216  };
217  if (JSON.stringify(previous) !== JSON.stringify(job))
218    await $.state.set(ref, job);
219}
220
221async function followLog($: Dollar, log: string): Promise<void> {
222  await update($, logs, (list) =>
223    [...(list ?? []).filter((one) => one !== log), log].slice(-MAX_LOGS),
224  );
225}
226
227function glyph(props: {
228  isRunning: boolean;
229  isErrored: boolean;
230  isInterrupted: boolean;
231}) {
232  if (props.isInterrupted) return { mark: "⏹", color: "warning" };
233  if (props.isRunning) return { mark: "…", color: "claude" };
234  if (props.isErrored) return { mark: "✗", color: "error" };
235  return { mark: "✓", color: "success" };
236}
237
238function jobSummary(job: NushellJob): { text: string; color: string } {
239  const name = job.log.split("/").pop() ?? job.log;
240  if (job.isLost)
241    return { text: `${name} · lost: its Nushell exited first`, color: "error" };
242  if (job.exitCode !== undefined) {
243    const failed = job.exitCode !== 0;
244    const error = job.error !== undefined ? ` · ${job.error}` : "";
245    return {
246      text: `${name} · exit ${job.exitCode}${error}`,
247      color: failed ? "error" : "success",
248    };
249  }
250  if (job.isDone)
251    return { text: `${name} · no longer followed`, color: "warning" };
252  const phase = job.phase !== undefined ? ` · ${job.phase}` : "";
253  return { text: `${name} · running${phase}`, color: "claude" };
254}
255
256export const register: Register = (on) => {
257  // One poller for the session follows every log not yet finished. A reload
258  // cancels it with the old module, and session.start arms it again.
259  on("session.start", async ($, e, next) => {
260    let isBusy = false;
261    $.clock.every(POLL_MS, () => {
262      if (isBusy) return;
263      isBusy = true;
264      void (async () => {
265        const list = (await read($, logs)) ?? [];
266        const now = await $.clock.now();
267        for (const log of list) {
268          const { value: job } = await $.state.get({ ...jobs, id: log });
269          if (job?.isDone !== true)
270            await poll($, log, now).catch(() => undefined);
271        }
272      })().finally(() => {
273        isBusy = false;
274      });
275    });
276    return next(e);
277  });
278
279  on("tool.call", async ($, e, next) => {
280    const ran = await next(e);
281    if (EVALUATE.test(e.tool) && !("deny" in ran && ran.deny !== undefined)) {
282      const log = jobLog(ran.text ?? ran.result);
283      if (log !== undefined) await followLog($, log).catch(() => undefined);
284    } else if (e.tool === "Monitor") {
285      const log = monitoredLog((e as { command?: unknown }).command, []);
286      if (log !== undefined) await followLog($, log).catch(() => undefined);
287    }
288    return ran;
289  });
290
291  on(
292    "ui.render",
293    { component: "ToolUse", props: { tool: EVALUATE } },
294    async ($, e, next) => {
295      const input = e.props.input as { input?: unknown } | undefined;
296      if (typeof input?.input !== "string") return next(e);
297      const { Box, Button, Code, Text } = $.ui.resolve(e);
298      const { title, body } = splitSource(input.input);
299      const isExpanded =
300        (await read($, { ...expanded, id: e.requestId })) ?? false;
301      const shown = isExpanded ? body : body.slice(0, FOLDED_LINES);
302      const hidden = body.length - shown.length;
303      const { mark, color } = glyph(e.props);
304      const log =
305        e.props.output === undefined ? undefined : jobLog(e.props.output);
306      const job =
307        log === undefined ? undefined : await read($, { ...jobs, id: log });
308      const toggle = { ...expanded, id: e.requestId };
309
310      return (
311        <Box flexDirection="column">
312          <Box flexDirection="row" gap={1}>
313            <Text color={color} bold>
314              {mark}
315            </Text>
316            <Text bold wrap="truncate-end">
317              {title}
318            </Text>
319          </Box>
320          {shown.length > 0 && (
321            <Box paddingLeft={2}>
322              <Code
323                source={shown.join("\n")}
324                language="nu"
325                wrap="truncate-end"
326              />
327            </Box>
328          )}
329          {(hidden > 0 || (isExpanded && body.length > FOLDED_LINES)) && (
330            <Box paddingLeft={2}>
331              <Button
332                key="fold"
333                plain
334                label={
335                  isExpanded ? "Fold the source" : `Show ${hidden} more lines`
336                }
337                onPress={() => update($, toggle, (value) => !(value ?? false))}
338              />
339            </Box>
340          )}
341          {log !== undefined && jobBlock({ job, log, Box, Code, Text })}
342        </Box>
343      );
344    },
345  );
346
347  on(
348    "ui.render",
349    { component: "ToolUse", props: { tool: "Monitor" } },
350    async ($, e, next) => {
351      const input = e.props.input as
352        | { command?: unknown; description?: unknown }
353        | undefined;
354      const log = monitoredLog(input?.command, (await read($, logs)) ?? []);
355      if (log === undefined) return next(e);
356      const job = await read($, { ...jobs, id: log });
357      if (job === undefined) return next(e);
358      const { Box, Code, Text } = $.ui.resolve(e);
359      const { mark, color } = glyph(e.props);
360      const description =
361        typeof input?.description === "string" ? input.description : "Monitor";
362
363      return (
364        <Box flexDirection="column">
365          <Box flexDirection="row" gap={1}>
366            <Text color={color} bold>
367              {mark}
368            </Text>
369            <Text bold wrap="truncate-end">
370              {description}
371            </Text>
372          </Box>
373          {jobBlock({ job, log, Box, Code, Text })}
374        </Box>
375      );
376    },
377  );
378};
379
380type Elements = ReturnType<Dollar["ui"]["resolve"]>;
381
382// The bordered block of a job's status line and log tail.
383function jobBlock(props: {
384  job: NushellJob | undefined;
385  log: string;
386  Box: Elements["Box"];
387  Code: Elements["Code"];
388  Text: Elements["Text"];
389}) {
390  const { job, log, Box, Code, Text } = props;
391  const summary =
392    job === undefined
393      ? {
394          text: `${log.split("/").pop()} · waiting for output`,
395          color: "subtle",
396        }
397      : jobSummary(job);
398  return (
399    <Box
400      flexDirection="column"
401      marginLeft={2}
402      borderStyle="round"
403      borderDimColor
404      paddingX={1}
405    >
406      <Text color={summary.color} wrap="truncate-end">
407        {summary.text}
408      </Text>
409      {job !== undefined && job.tail.length > 0 && (
410        <Code source={job.tail.join("\n")} wrap="truncate-end" />
411      )}
412    </Box>
413  );
414}
415
types/index.d.ts 27 lines
1// What the card knows of one followed job, keyed by its log's absolute path.
2export type NushellJob = {
3  log: string;
4  // The last lines of the log, escape sequences and blank lines removed.
5  tail: string[];
6  // The latest OSC 7501 report, as `state · msg`.
7  phase?: string;
8  isDone: boolean;
9  exitCode?: number;
10  error?: string;
11  // The Nushell that ran the job exited without recording an outcome.
12  isLost?: boolean;
13  startedAt: number;
14};
15
16declare module "claude-code" {
17  interface PluginState {
18    "nushell-cards": {
19      jobs: StateFamily<NushellJob>;
20      // Logs to follow, oldest first.
21      logs: string[];
22      // Whether a card shows its whole source, by tool_use_id.
23      expanded: StateFamily<boolean>;
24    };
25  }
26}
27