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.

hooks/register.tsx 415 lines1import { 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}
415types/index.d.ts 27 lines1// 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