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

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.
| Plugin | What it does |
|---|---|
| time | The time you sent each message, drawn above it |
| gh-ci-status | GitHub Actions runs of the session's repo, pinned above the prompt, with links to the PR and the run |
| activity-log | Every event of the session, tool calls and their full results included, appended to a JSONL file per session |
| agent-flow | A live tree of the session's subagents and teammates in a pane: status, current tool, time, calls and tokens |
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.
⚙ owner/repo · Actions · 1 running · 1 finished
#167 ◐ Running PR 0m37s chore(ci): smoke-test PR
main ● Success Deploy 2m15s Release 1.4.0
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.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.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.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.
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.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.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.tool.describe, command.describe and the $ calls of other plugins.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
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.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.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./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.# 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.
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.
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:testsrc/hooks/register.ts 256 lines1// 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};
256src/core/record.ts 63 lines1// 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}
63src/core/serialize.ts 94 lines1// 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}
94src/infra/appender.ts 81 lines1// 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