SLOPSHOPPER

activity-log

Every event of the session, tool calls and their full results included, appended to a JSONL file per session

newprocesstimer
v0.1.0no licenseupdated 2026-10-05diegorv/claude-mods-diegorv/plugins/activity-log
A shopper browsing a rack in a slop shop
README

claude-mods-diegorv

Claude Code mods, packaged as a plugin marketplace. A mod is a plugin whose hooks module (TypeScript, here) runs inside the session. Mods need Claude Code 2.1.287 or later and are on by default.

PluginWhat it does
timeThe time you sent each message, drawn above it
gh-ci-statusGitHub Actions runs of the session's repo, pinned above the prompt, with links to the PR and the run
activity-logEvery event of the session, tool calls and their full results included, appended to a JSONL file per session
agent-flowA live tree of the session's subagents and teammates in a pane: status, current tool, time, calls and tokens

time

The time above a message is the one of its first drawing, which is when you sent it, and it is kept per message, so a resize or a redraw does not move it. A resumed session, or a reload of the plugin, draws the old messages with the current time.

gh-ci-status

⚙ owner/repo · Actions · 1 running · 1 finished
#167  ◐ Running    PR       0m37s  chore(ci): smoke-test PR
main  ● Success    Deploy   2m15s  Release 1.4.0
  • Finds the repo the way gh does (gh repo view, the default remote), so gh must be on your PATH and logged in. Without a GitHub remote it stays quiet. The check runs at session start; if it fails there, no network or an expired token, it runs again at the next command that wakes the band (below), so after gh auth login, or once the network is back, a push is enough. Each failure goes to the debug log (claude --debug). While gh fails, the header says so (gh error for 2m05s) and keeps the last rows.
  • Polls gh run list every 60 s, and every 15 s while a run is in flight or for 6 minutes after a push. A git push, git subtree push, gh pr merge, gh workflow run or gh run rerun in Bash wakes it.
  • #N links to the PR (matched by branch through gh pr list, or from refs/pull/N/head); the workflow name (on a narrow band, the status) links to the run.
  • Shows runs from a push, a pull request, a manual dispatch, the merge queue, a release, or a workflow another one started, and leaves cron and issue bots out. Toasts when a run starts and when runs finish: one per run that failed or needs you, one for those that passed; a cancelled or skipped run shows only on the band. A finished run stays for 5 minutes; one that failed, timed out or needs you stays until a newer run of the same workflow, branch and trigger shows up, for 30 minutes at most.
  • details on the band's header opens a pane with every run of the last list, and under each failed one its failed jobs and steps, each linking to the job: ctrl+x tab to focus the band, then d; d or Esc closes it. A mouse click reaches it only in fullscreen.
  • /gh-ci opens or closes the same pane, band or not; before the repo is found it looks again and says so.

activity-log

Writes everything the session does to ~/.claude/activity-log/<date>-<sessionId>.jsonl, one JSON object per line. The date is the local date of the session's first line; after a /clear the lines go to a new file, under the new session id.

  • Each event is two lines with the same seq: a start line with the event's input (and origin, who raised it), written before the event runs, then an end line with its result and durationMs, or an error line. Tool calls, the rows the conversation keeps, the model's requests, prompts, commands, agents and the $ calls of other plugins are all there.
  • The content is whole, nothing cut: a tool's full input and result, every message. An event that runs in a subagent has its agentId on the start line. The model's response and a plugin's spawned process stream; their end line lists the chunks, which pass through unchanged as they come.
  • The files hold everything the session saw, secrets included. The folder is made 700 and each file 600, but nothing is redacted: delete what you do not want kept.
  • Left out: ui.render and ui.resolve, which fire for each component on every redraw; prompt.edit, which fires on every keystroke with the draft so far (prompt.submit keeps the text sent); and this plugin's own calls. Telemetry is only the collector stream of telemetry.log; the anthropic stream, and telemetry.mark with it, is closed to installed plugins.
  • It is large: about 2 MB for a one-line prompt, mostly tool.describe, command.describe and the $ calls of other plugins.
  • Lines are written about once a second and at session end. After a hot reload of the plugin, seq starts again at 0 in the same file, and the last second of lines may be lost.

Reading it with jq:

# tool calls, with input and result side by side
jq -s -c 'map(select(.event == "tool.call")) | group_by(.seq)[]
  | {tool: .[0].input.tool, agentId: .[0].agentId, input: .[0].input, result: .[1].result}' FILE

# one subagent's lines (its ids: jq -r '.agentId // empty' FILE | sort -u)
jq -s -c --arg id AGENT_ID 'map(select(.agentId == $id) | .seq) as $seqs
  | .[] | select(.seq | IN($seqs[]))' FILE

# how many of each event
jq -r 'select(.phase == "start") | .event' FILE | sort | uniq -c | sort -rn

agent-flow

2 running · 1 done · 1 waiting
● main · running 1m24s · Agent 1m23s
  ● general-purpose: find the auth flow · running 1m23s · Grep 4s · 7 calls
    ✓ Explore: read the session store · completed 36s · 12 calls · 20.4k tok
  ● general-purpose: run the test suite · running 1m22s · waiting for approval: Bash · 3 calls
  • /flow opens a pane with the main loop and every subagent and teammate under the loop that spawned it; /flow again closes it. The pane does not take the keyboard. Terminal only.
  • A row: status glyph, type and name (or the task's description), status and elapsed time, the oldest tool call in flight and how long it has run, the calls ended, and once the agent ended its tokens. The header counts the agents running, those done, and the loops waiting for approval.
  • A row stands out (bold, colored) while its loop waits for approval, while a tool call has run over 30 s (an Agent call, which lasts as long as its child, does not count), or while a running agent has had no event for 2 minutes. Ended rows are dimmed.
  • The tree comes from the engine's events (agent.spawn, tool.call, turn.start, turn.complete, and classic.PermissionRequest when no settings hook decided it), checked against $.agent.list() every 2 s while something runs and the pane is open; the list has the last word on status. An agent the list named once and then left out is marked gone.
  • Tokens are uncached input plus output, summed over the agent's turns; cache reads and writes are left out.
  • "waiting for approval" stays until the tool call ends, approved or not: no event carries the person's answer. In auto or dontAsk mode, or for a background subagent, it can show briefly before an automatic deny; it clears when the call ends.
  • The engine's own forks (compaction, memory) are not shown, and events from loops no spawn or list named (a workflow's agents) are ignored.
  • /clear and /resume start the tree empty; a background agent that survives /clear comes back once the list names it (while something runs with the pane open). A reload of the plugin starts it empty too; run /flow to draw the pane again, and running agents come back once the list names them.
  • Based on the idea of claude-agent-flow by Charlie0113-T (Apache-2.0); no code is copied.

Use

# mods need Claude Code 2.1.287 or later
claude --plugin-dir /path/to/claude-mods-diegorv/plugins

That folder loads activity-log too, which writes everything the session sees to disk; to leave it out, point --plugin-dir at single plugins instead (--plugin-dir plugins/time).

Under --plugin-dir, edits to a plugin's files reload it without restarting the session.

Or install them from the marketplace on GitHub:

/plugin marketplace add diegorv/claude-mods-diegorv
/plugin install activity-log@claude-mods-diegorv
/plugin install agent-flow@claude-mods-diegorv
/plugin install gh-ci-status@claude-mods-diegorv
/plugin install time@claude-mods-diegorv

Or from a local clone, by the path of its root (the folder with .claude-plugin/marketplace.json, not plugins/):

/plugin marketplace add /path/to/claude-mods-diegorv
/plugin install activity-log@claude-mods-diegorv
/plugin install agent-flow@claude-mods-diegorv
/plugin install gh-ci-status@claude-mods-diegorv
/plugin install time@claude-mods-diegorv

A marketplace added from a local path loads its plugins in place, so edits need no version bump or reinstall; they take effect at the next session start or /reload-plugins, not on save as under --plugin-dir. Both marketplaces are named claude-mods-diegorv, and only one marketplace per name can be registered, so remove one before adding the other.

The marketplace was renamed, and the install ids with it. If you installed under the old name, run /plugin marketplace remove claude-function-hooks, which also uninstalls its plugins, then one of the two marketplace blocks above.

Tested with Claude Code 2.1.289; the API may still change between releases. /plugin names the mods the session loaded, in a line such as 1 mod active · time. The old CLAUDE_CODE_ENABLE_FUNCTION_HOOKS flag is ignored; remove it.

To turn one off, disable its plugin in the Installed tab of /plugin. To turn off every installed mod, start the session with --safe-mode, which also disables your other customizations, or, for every session, set "disableAllHooks": true in ~/.claude/settings.json, which also stops your settings hooks and custom status line.

Develop

npm test           # node --test, no dependencies
npm run typecheck  # tsc
npm run format     # prettier
npm run validate   # claude plugin validate

claude plugin test runs every *.test.ts in a plugin with the claude-code/testing kit; these use node:test, so it is not used here.

The API's type declarations are not in git. Claude Code writes them to plugins/<name>/.claude-plugin/types/ each time it loads the plugins with --plugin-dir (a one-prompt headless run, claude -p --plugin-dir plugins "ok", will do). Before npm run typecheck, load the plugins once, and again after updating Claude Code.

Plugin layout

plugins/gh-ci-status/
  .claude-plugin/plugin.json   manifest
  hooks/hooks.json             points at the entry module
  src/core/                    the run model and the text derived from it, no I/O
  src/utils/                   generic helpers (text)
  src/app/                     use cases (the poller, the start-up), dependencies injected
  src/infra/                   external clients (GitHub through gh)
  src/components/              the band's view model and its JSX
  src/hooks/                   the wiring to the engine
  *.test.ts                    next to the file it tests, run with node:test
Source 4 files
src/hooks/register.ts 256 lines
1// Records every event of the session to ~/.claude/activity-log/<date>-<session>.jsonl:
2// one `*` hook (telemetry.log has its own) that writes a start line, calls next with `e`
3// untouched, then writes the end (or error) line and hands back what next gave, the same
4// value or error.
5//
6//   telemetry.log          hooked by name, its collector stream: `*` selects no telemetry event
7//   ui.render, ui.resolve  passed on unrecorded: one per component per redraw
8//   prompt.edit            passed on unrecorded: one per keystroke, drafts included;
9//                          prompt.submit keeps the text sent
10//   this plugin's own $    passed on unrecorded (next.origin), or appending would log itself
11//   turn.step, a spawn     hooked on their own, as generators: the stream is relayed chunk
12//                          by chunk as it comes, and the end line lists the chunks
13//   session.end            flushes what is waiting; after a /clear the next lines go to
14//                          the new session's file
15import type { Register, StarNext } from "claude-code";
16import { agentIdOf, logPath, outcomeLine, startLine, type Settled } from "../core/record.ts";
17import { errorData } from "../core/serialize.ts";
18import { appendFile, createAppender, type ProcessResult } from "../infra/appender.ts";
19
20const FLUSH_MS = 1000;
21const NEW_ID_TRIES = 20; // after a /clear, how many times locate waits for the new session id
22const NEW_ID_WAIT_MS = 250;
23
24type Session = {
25  seq: number;
26  firstAt: Date | null; // the first line's time: the file's date
27  path: Promise<string>; // settled by locate
28  settle: (path: Promise<string>) => void;
29  located: boolean;
30  endedId: string | null; // the session a /clear ended, whose id this one must not take
31};
32
33function newSession(endedId: string | null): Session {
34  let settle: (path: Promise<string>) => void = () => {};
35  const path = new Promise<string>((resolve) => (settle = resolve));
36  path.catch(() => {}); // a failure is reported where the lines are written
37  return { seq: 0, firstAt: null, path, settle, located: false, endedId };
38}
39
40const isThenable = (value: unknown): value is PromiseLike<unknown> =>
41  typeof value === "object" && value !== null && typeof (value as PromiseLike<unknown>).then === "function";
42const since = (startedAt: number) => Math.round((performance.now() - startedAt) * 10) / 10;
43
44// What the log needs of `$`, outside the hook that built it: the engine refuses a module
45// that keeps `$` itself, so these closures call it as `$.noun.event(...)`.
46type Host = {
47  name: string;
48  run: (argv: readonly string[], init?: { stdin?: string; timeoutMs?: number }) => Promise<ProcessResult>;
49  after: (ms: number, fn: () => void) => void;
50  sleep: (ms: number) => Promise<void>;
51  home: () => Promise<string | undefined>;
52  sessionId: () => Promise<string>;
53  log: (text: string) => void;
54};
55
56export const register: Register = (on) => {
57  // Built by the `*` hook at the first event with a real `$` (engine.create's is an empty
58  // table); plugin.register and session.start come before any stream.
59  let host: Host | null = null;
60  let session = newSession(null);
61  let reported = false;
62
63  // Once per module: the log failing never fails the session, it says so in the debug log.
64  const report = (error: unknown) => {
65    if (reported) return;
66    reported = true;
67    try {
68      host?.log(`activity-log: not written: ${errorData(error).message}`);
69    } catch {
70      // nothing left to tell
71    }
72  };
73
74  const appender = createAppender(
75    (path, text) => {
76      if (!host) return Promise.reject(new Error("no engine to append with"));
77      return appendFile(host.run, path, text);
78    },
79    (ms, fn) => {
80      if (!host) throw new Error("no clock yet");
81      host.after(ms, fn);
82    },
83    report,
84    FLUSH_MS,
85  );
86
87  // Settles the session's file path, once, as soon as a real `$` and its first line exist.
88  const locate = (target: Session, knownId?: string) => {
89    if (target.located || !host || !target.firstAt) return;
90    target.located = true;
91    const $ = host;
92    const firstAt = target.firstAt;
93    const sessionId = async () => {
94      if (knownId) return knownId;
95      let id = await $.sessionId();
96      for (let tries = 0; id === target.endedId && tries < NEW_ID_TRIES; tries++) {
97        await $.sleep(NEW_ID_WAIT_MS);
98        id = await $.sessionId();
99      }
100      return id;
101    };
102    target.settle(
103      (async () => {
104        const [home, id] = await Promise.all([$.home(), sessionId()]);
105        if (!home) throw new Error("HOME is not set");
106        return logPath(home, firstAt, id);
107      })(),
108    );
109  };
110
111  const write = (target: Session, line: () => string) => {
112    try {
113      appender.add(target.path, line());
114    } catch (error) {
115      report(error);
116    }
117  };
118
119  // Writes the start line and answers the function that writes the end or error line.
120  const begin = (event: string, origin: unknown, e: unknown) => {
121    const target = session;
122    const seq = target.seq++;
123    const now = new Date();
124    target.firstAt ??= now;
125    locate(target);
126    write(target, () => startLine(now, seq, { event, origin, agentId: agentIdOf(e), input: e }));
127    const startedAt = performance.now();
128    return (outcome: Settled) =>
129      write(target, () => outcomeLine(new Date(), seq, event, { ...outcome, durationMs: since(startedAt) }));
130  };
131
132  // The stream as it comes, chunk by chunk and unchanged, `yield*` by hand: what the
133  // reader sends, throws or returns reaches the stream beneath.
134  async function* relay<C, R>(stream: AsyncGenerator<C, R>, finish: (outcome: Settled) => void): AsyncGenerator<C, R> {
135    const chunks: C[] = [];
136    let settled = false;
137    try {
138      let step = await stream.next();
139      while (!step.done) {
140        chunks.push(step.value);
141        let sent: unknown;
142        try {
143          sent = yield step.value;
144        } catch (thrown) {
145          step = await stream.throw(thrown);
146          continue;
147        }
148        step = await stream.next(sent);
149      }
150      settled = true;
151      finish({ phase: "end", result: step.value, chunks });
152      return step.value;
153    } catch (error) {
154      settled = true;
155      finish({ phase: "error", error, chunks });
156      throw error;
157    } finally {
158      if (!settled) {
159        // The reader stopped early: the stream beneath is closed as `yield*` would.
160        finish({ phase: "end", result: undefined, chunks, cancelled: true });
161        await stream.return(undefined as R);
162      }
163    }
164  }
165
166  // Writes what waits, within session.end's short bound: the signal ends the wait, not the write.
167  const flushBefore = (signal: AbortSignal) =>
168    Promise.race([
169      appender.flush(),
170      new Promise<void>((resolve) => {
171        if (signal.aborted) resolve();
172        signal.addEventListener("abort", () => resolve(), { once: true });
173      }),
174    ]);
175
176  const isOwn = (origin: { plugin: string } | undefined) => host !== null && origin?.plugin === host.name;
177
178  // One call, recorded: what the `*` and telemetry.log hooks share once `$` is taken care of.
179  const record = (e: unknown, next: StarNext) => {
180    const event = next.event;
181    if (isOwn(next.origin)) return next(e);
182
183    const finish = begin(event, next.origin, e);
184    // session.end: its lines are the ending session's last; what follows (after a /clear) is the next one's.
185    const ended = () => {
186      if (event !== "session.end") return null;
187      const target = session;
188      const sessionId = (e as { sessionId?: unknown }).sessionId;
189      const endedId = typeof sessionId === "string" ? sessionId : null;
190      locate(target, endedId ?? undefined);
191      session = newSession(endedId);
192      return flushBefore(next.signal);
193    };
194
195    let result: unknown;
196    try {
197      result = next(e);
198    } catch (error) {
199      finish({ phase: "error", error });
200      throw error;
201    }
202    if (!isThenable(result)) {
203      finish({ phase: "end", result });
204      return result;
205    }
206    return result.then(
207      async (value) => {
208        finish({ phase: "end", result: value });
209        await ended();
210        return value;
211      },
212      async (error: unknown) => {
213        finish({ phase: "error", error });
214        await ended();
215        throw error;
216      },
217    );
218  };
219
220  on("*", ($, e, next) => {
221    const event = next.event;
222    if (event === "ui.render" || event === "ui.resolve" || event === "prompt.edit") return next(e);
223    if (event !== "engine.create") {
224      host ??= {
225        name: $.plugin.name,
226        run: (argv, init) => $.process.run(argv, init),
227        after: (ms, fn) => void $.clock.after(ms, fn),
228        sleep: (ms) => $.clock.sleep(ms),
229        home: () => $.env.get("HOME"),
230        sessionId: () => $.session.id(),
231        log: (text) => $.ui.log(text, { to: "debug" }),
232      };
233    }
234    // A `*` hook sees a stream only as its result: the two hooks below relay its chunks.
235    if (event === "turn.step" || event === "process.spawn") return next(e);
236    return record(e, next);
237  });
238
239  // `*` selects no telemetry event for a plugin, and a plugin may hook only the operator's
240  // collector stream (`anthropic` is the built-ins'). This hook keeps no `$`: its lines wait
241  // until the `*` hook has.
242  on("telemetry.log", { to: "collector" }, ($, e, next) => record(e, next as unknown as StarNext) as never);
243
244  on("turn.step", async function* ($, e, next) {
245    if (isOwn(next.origin)) return yield* next(e);
246    const finish = begin(next.event, next.origin, e);
247    return yield* relay(next(e), finish);
248  });
249
250  on("process.spawn", async function* ($, e, next) {
251    if (isOwn(next.origin)) return yield* next(e);
252    const finish = begin(next.event, next.origin, e);
253    return yield* relay(next(e), finish);
254  });
255};
256
src/core/record.ts 63 lines
1// The log's lines and where they go: one JSON object per line, two per event
2// (start, then end or error) sharing a `seq`, in a file per session.
3import { errorData, toPlain } from "./serialize.ts";
4
5export type Start = { event: string; origin?: unknown; agentId?: string; input: unknown };
6// How a call settled: `chunks` for a stream, `cancelled` when its reader stopped early.
7export type Settled =
8  | { phase: "end"; result: unknown; chunks?: unknown[]; cancelled?: boolean }
9  | { phase: "error"; error: unknown; chunks?: unknown[] };
10export type Outcome = Settled & { durationMs: number };
11
12const twoDigits = (value: number) => String(value).padStart(2, "0");
13
14// The local date the file is named by: YYYY-MM-DD.
15export function localDate(date: Date): string {
16  return `${date.getFullYear()}-${twoDigits(date.getMonth() + 1)}-${twoDigits(date.getDate())}`;
17}
18
19export function logPath(home: string, date: Date, sessionId: string): string {
20  return `${home.replace(/\/+$/, "")}/.claude/activity-log/${localDate(date)}-${sessionId}.jsonl`;
21}
22
23// The loop an event ran in, when it names one (`agentId` on tool.call, session.append, ...).
24export function agentIdOf(e: unknown): string | undefined {
25  if (typeof e !== "object" || e === null) return undefined;
26  const agentId = (e as { agentId?: unknown }).agentId;
27  return typeof agentId === "string" ? agentId : undefined;
28}
29
30export function startLine(ts: Date, seq: number, start: Start): string {
31  const { event, origin, agentId, input } = start;
32  return `${JSON.stringify({
33    ts: ts.toISOString(),
34    seq,
35    phase: "start",
36    event,
37    ...(origin !== undefined && { origin: toPlain(origin) }),
38    ...(agentId !== undefined && { agentId }),
39    input: toPlain(input),
40  })}\n`;
41}
42
43export function outcomeLine(ts: Date, seq: number, event: string, outcome: Outcome): string {
44  const record =
45    outcome.phase === "end"
46      ? {
47          phase: "end",
48          event,
49          durationMs: outcome.durationMs,
50          result: toPlain(outcome.result),
51          ...(outcome.chunks && { chunks: toPlain(outcome.chunks) }),
52          ...(outcome.cancelled && { cancelled: true }),
53        }
54      : {
55          phase: "error",
56          event,
57          durationMs: outcome.durationMs,
58          error: toPlain(outcome.error instanceof Error ? outcome.error : errorData(outcome.error)),
59          ...(outcome.chunks && { chunks: toPlain(outcome.chunks) }),
60        };
61  return `${JSON.stringify({ ts: ts.toISOString(), seq, ...record })}\n`;
62}
63
src/core/serialize.ts 94 lines
1// Turns any value a hook sees into plain JSON data, whole: nothing is cut.
2// What JSON cannot hold gets a stand-in (a cycle, a function, a host object),
3// and nothing here throws: a getter that does is a "[Thrown: ...]" string.
4
5type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
6
7// btoa takes one char per byte; chunked so a large buffer does not overflow the call's arguments.
8function base64(bytes: Uint8Array): string {
9  let binary = "";
10  for (let at = 0; at < bytes.length; at += 0x8000) {
11    binary += String.fromCharCode(...bytes.subarray(at, at + 0x8000));
12  }
13  return btoa(binary);
14}
15
16// By shape: the environment's AbortSignal is a global the types give no `instanceof` to.
17const isAbortSignal = (value: object) =>
18  typeof (value as { aborted?: unknown }).aborted === "boolean" &&
19  typeof (value as { addEventListener?: unknown }).addEventListener === "function";
20
21// The class's name, or the tag a generator carries (its constructor has no name).
22const tag = (value: object) => `[${value.constructor?.name || Object.prototype.toString.call(value).slice(8, -1)}]`;
23
24export function errorData(error: unknown): { name: string; message: string; stack?: string } {
25  if (error instanceof Error) return { name: error.name, message: error.message, stack: error.stack };
26  return { name: typeof error, message: String(error) };
27}
28
29// One field of an object; a getter that throws gives a "[Thrown: ...]" string.
30function field(value: object, key: string, ancestors: Set<object>): Json | undefined {
31  try {
32    return plain((value as Record<string, unknown>)[key], ancestors);
33  } catch (error) {
34    return `[Thrown: ${errorData(error).message}]`;
35  }
36}
37
38// `ancestors` holds the objects on the path down to `value`: one seen twice
39// side by side is kept twice, only one inside itself is a cycle.
40function plain(value: unknown, ancestors: Set<object>): Json | undefined {
41  if (value === null || typeof value === "boolean" || typeof value === "string") return value;
42  if (typeof value === "number") return Number.isFinite(value) ? value : String(value);
43  if (typeof value === "bigint") return value.toString();
44  if (typeof value === "undefined") return undefined;
45  if (typeof value === "symbol") return value.toString();
46  if (typeof value === "function") return `[Function ${value.name || "anonymous"}]`;
47  if (ancestors.has(value)) return "[Circular]";
48  if (value instanceof Uint8Array) return { base64: base64(value) };
49  if (value instanceof ArrayBuffer) return { base64: base64(new Uint8Array(value)) };
50  if (ArrayBuffer.isView(value))
51    return { base64: base64(new Uint8Array(value.buffer, value.byteOffset, value.byteLength)) };
52  if (value instanceof Promise || isAbortSignal(value)) return tag(value);
53  if (Symbol.asyncIterator in (value as object) || Symbol.iterator in (value as object)) {
54    if (!Array.isArray(value) && !(value instanceof Map) && !(value instanceof Set)) return tag(value);
55  }
56  ancestors.add(value);
57  try {
58    if (value instanceof Error) {
59      // name, message, stack, then its own other fields (code, cause, errno...), enumerable or not.
60      const out: { [key: string]: Json } = { ...errorData(value) };
61      for (const key of Object.getOwnPropertyNames(value)) {
62        if (key in out) continue;
63        const item = field(value, key, ancestors);
64        if (item !== undefined) out[key] = item;
65      }
66      return out;
67    }
68    if (Array.isArray(value)) return value.map((item) => plain(item, ancestors) ?? null);
69    if (value instanceof Map)
70      return [...value].map(([key, item]) => [plain(key, ancestors) ?? null, plain(item, ancestors) ?? null]);
71    if (value instanceof Set) return [...value].map((item) => plain(item, ancestors) ?? null);
72    if (typeof (value as { toJSON?: unknown }).toJSON === "function") {
73      return plain((value as { toJSON: () => unknown }).toJSON(), ancestors);
74    }
75    const out: { [key: string]: Json } = {};
76    for (const key of Object.keys(value)) {
77      const item = field(value, key, ancestors);
78      if (item !== undefined) out[key] = item;
79    }
80    return out;
81  } finally {
82    ancestors.delete(value);
83  }
84}
85
86// The value as JSON data; undefined when the value is undefined (a field left out).
87export function toPlain(value: unknown): Json | undefined {
88  try {
89    return plain(value, new Set());
90  } catch (error) {
91    return `[Unserializable: ${errorData(error).message}]`;
92  }
93}
94
src/infra/appender.ts 81 lines
1// Appends lines to files, in the order they were added: the plugin API writes
2// whole files only, so each batch goes to `sh -c 'cat >> file'` on its stdin.
3// Lines wait in memory until `flush`, or until the timer `add` arms fires.
4export type ProcessResult = { exitCode: number; stdout: string; stderr: string };
5type RunProcess = (argv: readonly string[], init?: { stdin?: string; timeoutMs?: number }) => Promise<ProcessResult>;
6
7// Creates the file's folder on the way, as `$.fs.write` would. The file holds whatever the
8// session saw, secrets included: the folder is made 700 and a new file 600 (umask).
9export const APPEND = 'umask 077 && mkdir -p -m 700 "$(dirname "$1")" && cat >> "$1"';
10
11export async function appendFile(runProcess: RunProcess, path: string, text: string): Promise<void> {
12  const result = await runProcess(["sh", "-c", APPEND, "sh", path], { stdin: text, timeoutMs: 10_000 });
13  if (result.exitCode !== 0) throw new Error(result.stderr.trim() || `sh exited ${result.exitCode}`);
14}
15
16const DEAD_AFTER = 5;
17
18export type Appender = {
19  // `path` may still be resolving; lines for one path stay in order behind it.
20  add: (path: Promise<string>, line: string) => void;
21  // Writes everything added so far; resolves once it and every earlier flush are written.
22  flush: () => Promise<void>;
23};
24
25export function createAppender(
26  append: (path: string, text: string) => Promise<void>,
27  // Arms a one-shot timer; may throw (no engine to ask yet), and the next `add` tries again.
28  // A hook may refuse it and `fn` never runs: one armed for over DEAD_AFTER delays is dead.
29  after: (ms: number, fn: () => void) => void,
30  report: (error: unknown) => void,
31  delayMs = 1000,
32  now = Date.now,
33): Appender {
34  let pending: { path: Promise<string>; line: string }[] = [];
35  let written: Promise<void> = Promise.resolve();
36  let armedAt: number | null = null; // when the timer waiting now was armed
37
38  // A batch's runs of one path go out as one append each, one after the other.
39  const write = async (batch: typeof pending) => {
40    for (let at = 0; at < batch.length;) {
41      const path = batch[at]!.path;
42      let text = "";
43      for (; at < batch.length && batch[at]!.path === path; at++) text += batch[at]!.line;
44      try {
45        await append(await path, text);
46      } catch (error) {
47        report(error);
48      }
49    }
50  };
51
52  const flush = () => {
53    const batch = pending;
54    pending = [];
55    if (batch.length > 0) written = written.then(() => write(batch));
56    return written;
57  };
58
59  return {
60    add(path, line) {
61      pending.push({ path, line });
62      if (armedAt !== null) {
63        if (now() - armedAt <= DEAD_AFTER * delayMs) return;
64        armedAt = null;
65        void flush(); // the timer never fired: its lines go now, and a new one is armed
66      }
67      try {
68        const at = now();
69        after(delayMs, () => {
70          if (armedAt === at) armedAt = null; // a late dead timer leaves the new one armed
71          void flush();
72        });
73        armedAt = at;
74      } catch {
75        // retried by the next add
76      }
77    },
78    flush,
79  };
80}
81