@e9n/pi-logger 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/).
7
+
8
+ ## [0.1.0] - 2026-02-17
9
+
10
+ ### Added
11
+
12
+ - Initial release.
package/README.md ADDED
@@ -0,0 +1,105 @@
1
+ # @e9n/pi-logger
2
+
3
+ Event bus logger for [pi](https://github.com/mariozechner/pi-mono). Subscribes to events on the shared event bus and writes structured JSONL log files, one per day.
4
+
5
+ ## Features
6
+
7
+ - Captures any event on the pi event bus as a structured log entry
8
+ - Writes JSONL files named `YYYY-MM-DD.jsonl`, one per day
9
+ - **Global scope** — all sessions write to `~/.pi/agent/logs/`
10
+ - **Project scope** — logs write to `.pi/logs/` next to project code
11
+ - Configurable minimum level, event whitelist/ignore, and channel whitelist/ignore
12
+ - Level inferred from event name when not explicitly set
13
+ - Timestamps use the configured IANA timezone (defaults to system timezone)
14
+ - Runtime control via `/logger` command — change level, scope, or reload settings without restarting
15
+
16
+ ## Setup / Settings
17
+
18
+ Add to `~/.pi/agent/settings.json` (global) or `.pi/settings.json` (project):
19
+
20
+ ```json
21
+ {
22
+ "pi-logger": {
23
+ "level": "INFO",
24
+ "scope": "global",
25
+ "timezone": "Europe/Oslo",
26
+ "events_whitelist": ["log"],
27
+ "events_ignore": [],
28
+ "channels_whitelist": [],
29
+ "channels_ignore": []
30
+ }
31
+ }
32
+ ```
33
+
34
+ | Key | Default | Description |
35
+ |-----|---------|-------------|
36
+ | `level` | `"INFO"` | Minimum log level: `DEBUG`, `INFO`, `WARN`, `ERROR`. |
37
+ | `scope` | `"global"` | `"global"` → `~/.pi/agent/logs/`, `"project"` → `.pi/logs/`. |
38
+ | `timezone` | System tz | IANA timezone for timestamps (e.g. `"Europe/Oslo"`). |
39
+ | `events_whitelist` | `["log"]` | Bus event prefixes to subscribe to. `[]` = capture all known events. |
40
+ | `events_ignore` | `[]` | Bus event prefixes to skip (applied after whitelist). |
41
+ | `channels_whitelist` | `[]` | Channels to accept in the `log` handler. `[]` = all. |
42
+ | `channels_ignore` | `[]` | Channels to drop in the `log` handler. |
43
+
44
+ ### Logging from extensions
45
+
46
+ Emit structured log entries via the `"log"` bus event:
47
+
48
+ ```typescript
49
+ // Channel + level
50
+ pi.events.emit("log", { channel: "myext", level: "WARN", data: { msg: "something odd" } });
51
+
52
+ // Channel + sub-event
53
+ pi.events.emit("log", { channel: "myext", event: "request", data: { path: "/api" } });
54
+
55
+ // Shorthand by level (level inferred from event name)
56
+ pi.events.emit("log:error", { event: "myext:crash", data: { message: "oops" } });
57
+ pi.events.emit("log:warn", { event: "myext:slow", data: { ms: 2000 } });
58
+ ```
59
+
60
+ ### Level inference
61
+
62
+ Events are assigned a level from their name when no explicit level is given:
63
+
64
+ | Pattern in event name | Level |
65
+ |-----------------------|-------|
66
+ | `error` or `fail` | `ERROR` |
67
+ | `warn` or `alert` | `WARN` |
68
+ | `debug` | `DEBUG` |
69
+ | Anything else | `INFO` |
70
+
71
+ ### Log format
72
+
73
+ Each line is a JSON object:
74
+
75
+ ```json
76
+ {"ts":"2026-02-12T11:24:17.123+01:00","level":"INFO","channel":"heartbeat","event":"result","data":{"ok":true,"durationMs":3200}}
77
+ ```
78
+
79
+ The bus event name is split on the first `:` into `channel` and `event`:
80
+ `heartbeat:result` → `channel: "heartbeat"`, `event: "result"`
81
+
82
+ ### Known bus events captured
83
+
84
+ `channel:send/receive/register` · `cron:job_start/job_complete/add/remove/enable/disable/run/status/reload` · `heartbeat:check/result` · `jobs:recorded` · `web:mount/unmount/mount-api/unmount-api/ready` · `kysely:ready/ack`
85
+
86
+ For events not in this list, use the `log` / `log:*` protocol above.
87
+
88
+ ## Commands
89
+
90
+ | Command | Description |
91
+ |---------|-------------|
92
+ | `/logger` or `/logger status` | Show current settings and active subscription count |
93
+ | `/logger level <DEBUG\|INFO\|WARN\|ERROR>` | Change minimum log level for the current session |
94
+ | `/logger scope <global\|project>` | Change log scope for the current session |
95
+ | `/logger reload` | Reload settings from disk and resubscribe to events |
96
+
97
+ ## Install
98
+
99
+ ```bash
100
+ pi install npm:@e9n/pi-logger
101
+ ```
102
+
103
+ ## License
104
+
105
+ MIT
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@e9n/pi-logger",
3
+ "version": "0.1.0",
4
+ "description": "Event bus logger for pi — writes structured JSONL logs from bus events",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package"
8
+ ],
9
+ "pi": {
10
+ "extensions": [
11
+ "./src/index.ts"
12
+ ]
13
+ },
14
+ "scripts": {
15
+ "typecheck": "tsc --noEmit"
16
+ },
17
+ "author": "Espen Nilsen <hi@e9n.dev>",
18
+ "license": "MIT",
19
+ "peerDependencies": {
20
+ "@mariozechner/pi-coding-agent": "*"
21
+ },
22
+ "devDependencies": {
23
+ "typescript": "^5.9.3"
24
+ },
25
+ "files": [
26
+ "CHANGELOG.md",
27
+ "README.md",
28
+ "package.json",
29
+ "src"
30
+ ],
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/espennilsen/pi.git",
34
+ "directory": "extensions/pi-logger"
35
+ }
36
+ }
package/src/index.ts ADDED
@@ -0,0 +1,245 @@
1
+ /**
2
+ * pi-logger — Event bus logger for pi.
3
+ *
4
+ * Listens for events on `pi.events` and writes structured JSONL log files.
5
+ * All configuration lives under "pi-logger" in settings.json.
6
+ *
7
+ * Settings:
8
+ * level — Minimum log level: DEBUG | INFO | WARN | ERROR (default: INFO)
9
+ * scope — Where to write: "global" (~/.pi/agent/logs/) or "project" (.pi/logs/) (default: global)
10
+ * timezone — IANA timezone for timestamps (default: system timezone, e.g. "Europe/Oslo")
11
+ * events_whitelist — Bus event prefixes to subscribe to (default: ["log"] — captures log and log:*)
12
+ * events_ignore — Bus event prefixes to skip (default: [])
13
+ * channels_whitelist — Channels to accept in the "log" handler (default: [] = all)
14
+ * channels_ignore — Channels to drop in the "log" handler (default: [])
15
+ *
16
+ * Log entries split the bus event name into channel and event:
17
+ * "log:webserver" → channel: "log", event: "webserver"
18
+ * "heartbeat:result" → channel: "heartbeat", event: "result"
19
+ *
20
+ * Log events carry a level derived from the event name:
21
+ * ERROR-level events — names containing "error" or "fail"
22
+ * WARN-level events — names containing "warn" or "alert"
23
+ * DEBUG-level events — names containing "debug"
24
+ * INFO-level events — everything else
25
+ *
26
+ * Extensions emit structured logs via the "log" bus event:
27
+ * pi.events.emit("log", { channel: "webserver", level: "WARN", data: { ... } })
28
+ * pi.events.emit("log", { channel: "db", event: "slow-query", data: { ms: 500 } })
29
+ *
30
+ * Shorthand by level (level can still be overridden in payload):
31
+ * pi.events.emit("log:error", { event: "my-ext:crash", data: { ... } })
32
+ * pi.events.emit("log:warn", { event: "cache-miss", level: "ERROR", data: { ... } })
33
+ */
34
+
35
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
36
+ import { resolveSettings, type LogLevel, type LoggerSettings } from "./settings.ts";
37
+ import { writeLogEntry } from "./writer.ts";
38
+
39
+ // ── Level helpers ───────────────────────────────────────────────
40
+
41
+ const LEVEL_ORDER: Record<LogLevel, number> = {
42
+ DEBUG: 0,
43
+ INFO: 1,
44
+ WARN: 2,
45
+ ERROR: 3,
46
+ };
47
+
48
+ function shouldLog(minLevel: LogLevel, eventLevel: LogLevel): boolean {
49
+ return LEVEL_ORDER[eventLevel] >= LEVEL_ORDER[minLevel];
50
+ }
51
+
52
+ /** Infer a log level from an event name. */
53
+ function inferLevel(eventName: string): LogLevel {
54
+ const lower = eventName.toLowerCase();
55
+ if (lower.includes("error") || lower.includes("fail")) return "ERROR";
56
+ if (lower.includes("warn") || lower.includes("alert")) return "WARN";
57
+ if (lower.includes("debug")) return "DEBUG";
58
+ return "INFO";
59
+ }
60
+
61
+ // ── Filter helpers ──────────────────────────────────────────────
62
+
63
+ function matchesPrefix(name: string, prefixes: string[]): boolean {
64
+ if (prefixes.length === 0) return true;
65
+ return prefixes.some((p) => name === p || name.startsWith(p + ":") || name.startsWith(p + "."));
66
+ }
67
+
68
+ /** Check if a bus event should be subscribed to. */
69
+ function shouldCapture(name: string, settings: LoggerSettings): boolean {
70
+ if (settings.events_ignore.length > 0 && matchesPrefix(name, settings.events_ignore)) return false;
71
+ if (settings.events_whitelist.length > 0) return matchesPrefix(name, settings.events_whitelist);
72
+ return true;
73
+ }
74
+
75
+ /** Check if a channel from the "log" handler should be written. */
76
+ function shouldCaptureChannel(channel: string, settings: LoggerSettings): boolean {
77
+ if (settings.channels_ignore.length > 0 && settings.channels_ignore.includes(channel)) return false;
78
+ if (settings.channels_whitelist.length > 0) return settings.channels_whitelist.includes(channel);
79
+ return true;
80
+ }
81
+
82
+ // ── Extension entry point ───────────────────────────────────────
83
+
84
+ export default function (pi: ExtensionAPI) {
85
+ let settings: LoggerSettings;
86
+ let cwd = process.cwd();
87
+ const subscriptions: Array<() => void> = [];
88
+
89
+ // ── Setup / teardown ────────────────────────────────────────
90
+
91
+ function setup(): void {
92
+ settings = resolveSettings(cwd);
93
+ teardown();
94
+
95
+ // Main log handler: pi.events.emit("log", { level?, channel?, event?, data })
96
+ //
97
+ // All custom logging goes through "log". Extensions set their own level
98
+ // and channel in the payload. If level is omitted it defaults to INFO.
99
+ // The channel field controls the channel/event split in the log file:
100
+ // emit("log", { channel: "webserver", level: "WARN", data: { ... } })
101
+ // → { channel: "webserver", event: "", level: "WARN", ... }
102
+ // emit("log", { channel: "webserver", event: "request", data: { ... } })
103
+ // → { channel: "webserver", event: "request", level: "INFO", ... }
104
+ //
105
+ // channels_whitelist / channels_ignore filter which channels are written.
106
+ subscriptions.push(pi.events.on("log", (payload: unknown) => {
107
+ const p = payload as Record<string, any> | undefined;
108
+ if (!p || typeof p !== "object") return;
109
+ const channel = typeof p.channel === "string" ? p.channel : "log";
110
+ if (!shouldCaptureChannel(channel, settings)) return;
111
+ const level = (typeof p.level === "string" && LEVEL_ORDER[p.level.toUpperCase() as LogLevel] !== undefined
112
+ ? p.level.toUpperCase()
113
+ : "INFO") as LogLevel;
114
+ if (!shouldLog(settings.level, level)) return;
115
+ const event = typeof p.event === "string" ? p.event : "";
116
+ const busEvent = event ? `${channel}:${event}` : channel;
117
+ writeLogEntry(busEvent, level, p.data ?? null, settings.scope, cwd, settings.timezone);
118
+ }));
119
+
120
+ // Shorthand: log:debug, log:info, log:warn, log:error
121
+ // Level is inferred from the event name but can be overridden in payload.
122
+ for (const lvl of ["debug", "info", "warn", "error"] as const) {
123
+ const defaultLevel = lvl.toUpperCase() as LogLevel;
124
+ subscriptions.push(pi.events.on(`log:${lvl}`, (payload: unknown) => {
125
+ const p = payload as Record<string, any> | undefined;
126
+ const level = (typeof p?.level === "string" && LEVEL_ORDER[p.level.toUpperCase() as LogLevel] !== undefined
127
+ ? p.level.toUpperCase()
128
+ : defaultLevel) as LogLevel;
129
+ if (!shouldLog(settings.level, level)) return;
130
+ const event = typeof p?.event === "string" ? p.event : `log:${lvl}`;
131
+ writeLogEntry(event, level, p?.data ?? p ?? null, settings.scope, cwd, settings.timezone);
132
+ }));
133
+ }
134
+
135
+ // Subscribe to well-known bus events.
136
+ // For events not in this list, extensions should use the log/log:* protocol.
137
+ const knownEvents = [
138
+ "channel:send", "channel:receive", "channel:register",
139
+ "cron:job_start", "cron:job_complete", "cron:add", "cron:remove",
140
+ "cron:enable", "cron:disable", "cron:run", "cron:status", "cron:reload",
141
+ "heartbeat:check", "heartbeat:result",
142
+ "jobs:recorded",
143
+ "web:mount", "web:unmount", "web:mount-api", "web:unmount-api", "web:ready",
144
+ "kysely:ready", "kysely:ack",
145
+ ];
146
+
147
+ for (const eventName of knownEvents) {
148
+ if (!shouldCapture(eventName, settings)) continue;
149
+ subscriptions.push(pi.events.on(eventName, (data: unknown) => {
150
+ const level = inferLevel(eventName);
151
+ if (!shouldLog(settings.level, level)) return;
152
+ writeLogEntry(eventName, level, data ?? null, settings.scope, cwd, settings.timezone);
153
+ }));
154
+ }
155
+ }
156
+
157
+ function teardown(): void {
158
+ for (const unsub of subscriptions) unsub();
159
+ subscriptions.length = 0;
160
+ }
161
+
162
+ // ── Lifecycle ───────────────────────────────────────────────
163
+
164
+ pi.on("session_start", async (_event, ctx) => {
165
+ cwd = ctx.cwd;
166
+ setup();
167
+ writeLogEntry("logger:start", "INFO", {
168
+ scope: settings.scope,
169
+ level: settings.level,
170
+ timezone: settings.timezone,
171
+ events_whitelist: settings.events_whitelist,
172
+ events_ignore: settings.events_ignore,
173
+ channels_whitelist: settings.channels_whitelist,
174
+ channels_ignore: settings.channels_ignore,
175
+ }, settings.scope, cwd, settings.timezone);
176
+ });
177
+
178
+ pi.on("session_shutdown", async () => {
179
+ writeLogEntry("logger:stop", "INFO", null, settings.scope, cwd, settings.timezone);
180
+ teardown();
181
+ });
182
+
183
+ // ── Command: /logger ────────────────────────────────────────
184
+
185
+ pi.registerCommand("logger", {
186
+ description: "Show logger status or change settings: /logger [status|level <LVL>|scope <global|project>|reload]",
187
+ getArgumentCompletions: (prefix: string) => {
188
+ const items = [
189
+ { value: "status", label: "status — Show current logger settings" },
190
+ { value: "level", label: "level <DEBUG|INFO|WARN|ERROR> — Change log level" },
191
+ { value: "scope", label: "scope <global|project> — Change log scope" },
192
+ { value: "reload", label: "reload — Reload settings from disk" },
193
+ ];
194
+ return items.filter((i) => i.value.startsWith(prefix));
195
+ },
196
+ handler: async (args, ctx) => {
197
+ const parts = (args ?? "").trim().split(/\s+/);
198
+ const cmd = parts[0]?.toLowerCase();
199
+
200
+ if (cmd === "level" && parts[1]) {
201
+ const lvl = parts[1].toUpperCase() as LogLevel;
202
+ if (LEVEL_ORDER[lvl] === undefined) {
203
+ ctx.ui.notify("Invalid level. Use: DEBUG, INFO, WARN, ERROR", "error");
204
+ return;
205
+ }
206
+ settings.level = lvl;
207
+ ctx.ui.notify(`Log level set to ${lvl}`, "info");
208
+ writeLogEntry("logger:level_change", "INFO", { level: lvl }, settings.scope, cwd, settings.timezone);
209
+ return;
210
+ }
211
+
212
+ if (cmd === "scope" && parts[1]) {
213
+ const s = parts[1].toLowerCase();
214
+ if (s !== "global" && s !== "project") {
215
+ ctx.ui.notify("Invalid scope. Use: global, project", "error");
216
+ return;
217
+ }
218
+ settings.scope = s;
219
+ ctx.ui.notify(`Log scope set to ${s}`, "info");
220
+ writeLogEntry("logger:scope_change", "INFO", { scope: s }, settings.scope, cwd, settings.timezone);
221
+ return;
222
+ }
223
+
224
+ if (cmd === "reload") {
225
+ setup();
226
+ ctx.ui.notify(`Logger reloaded: level=${settings.level}, scope=${settings.scope}, tz=${settings.timezone}`, "info");
227
+ return;
228
+ }
229
+
230
+ // Default: status
231
+ const fmt = (arr: string[], label: string) =>
232
+ arr.length > 0 ? `${label}: ${arr.join(", ")}` : `${label}: all`;
233
+ const lines = [
234
+ `Logger: level=${settings.level}, scope=${settings.scope}`,
235
+ `Timezone: ${settings.timezone}`,
236
+ fmt(settings.events_whitelist, "Events whitelist"),
237
+ settings.events_ignore.length > 0 ? `Events ignore: ${settings.events_ignore.join(", ")}` : "Events ignore: none",
238
+ fmt(settings.channels_whitelist, "Channels whitelist"),
239
+ settings.channels_ignore.length > 0 ? `Channels ignore: ${settings.channels_ignore.join(", ")}` : "Channels ignore: none",
240
+ `Subscriptions: ${subscriptions.length}`,
241
+ ];
242
+ ctx.ui.notify(lines.join("\n"), "info");
243
+ },
244
+ });
245
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * pi-logger — Settings loader.
3
+ *
4
+ * Reads "pi-logger" from global and project settings.json, merges them
5
+ * (project overrides global).
6
+ */
7
+
8
+ import { getAgentDir, SettingsManager } from "@mariozechner/pi-coding-agent";
9
+
10
+ export type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR";
11
+
12
+ export type LogScope = "global" | "project";
13
+
14
+ export interface LoggerSettings {
15
+ /** Minimum level to write. Events below this are discarded. */
16
+ level: LogLevel;
17
+ /** Where to store logs: "global" → ~/.pi/agent/logs/, "project" → .pi/logs/ */
18
+ scope: LogScope;
19
+ /** IANA timezone for timestamps. Defaults to system tz. Example: "Europe/Oslo" */
20
+ timezone: string;
21
+ /** Bus event prefixes to subscribe to. Default: ["log"] (captures log, log:*). */
22
+ events_whitelist: string[];
23
+ /** Bus event prefixes to ignore (applied after whitelist). */
24
+ events_ignore: string[];
25
+ /** Channel whitelist for the "log" handler. Empty = accept all channels. */
26
+ channels_whitelist: string[];
27
+ /** Channels to ignore (applied after whitelist). */
28
+ channels_ignore: string[];
29
+ }
30
+
31
+ /** Detect the system timezone via Intl. Falls back to UTC. */
32
+ function systemTimezone(): string {
33
+ try {
34
+ return Intl.DateTimeFormat().resolvedOptions().timeZone;
35
+ } catch {
36
+ return "UTC";
37
+ }
38
+ }
39
+
40
+ const DEFAULTS: LoggerSettings = {
41
+ level: "INFO",
42
+ scope: "global",
43
+ timezone: systemTimezone(),
44
+ events_whitelist: ["log"],
45
+ events_ignore: [],
46
+ channels_whitelist: [],
47
+ channels_ignore: [],
48
+ };
49
+
50
+ const VALID_LEVELS: LogLevel[] = ["DEBUG", "INFO", "WARN", "ERROR"];
51
+
52
+ export function resolveSettings(cwd: string): LoggerSettings {
53
+ try {
54
+ const agentDir = getAgentDir();
55
+ const sm = SettingsManager.create(cwd, agentDir);
56
+ const global = sm.getGlobalSettings() as Record<string, any>;
57
+ const project = sm.getProjectSettings() as Record<string, any>;
58
+ const cfg = { ...(global?.["pi-logger"] ?? {}), ...(project?.["pi-logger"] ?? {}) };
59
+
60
+ const rawLevel = (cfg.level ?? "").toUpperCase();
61
+
62
+ return {
63
+ level: VALID_LEVELS.includes(rawLevel as LogLevel) ? (rawLevel as LogLevel) : DEFAULTS.level,
64
+ scope: cfg.scope === "project" ? "project" : DEFAULTS.scope,
65
+ timezone: typeof cfg.timezone === "string" && cfg.timezone ? cfg.timezone : DEFAULTS.timezone,
66
+ events_whitelist: Array.isArray(cfg.events_whitelist) ? cfg.events_whitelist : DEFAULTS.events_whitelist,
67
+ events_ignore: Array.isArray(cfg.events_ignore) ? cfg.events_ignore : DEFAULTS.events_ignore,
68
+ channels_whitelist: Array.isArray(cfg.channels_whitelist) ? cfg.channels_whitelist : DEFAULTS.channels_whitelist,
69
+ channels_ignore: Array.isArray(cfg.channels_ignore) ? cfg.channels_ignore : DEFAULTS.channels_ignore,
70
+ };
71
+ } catch {
72
+ return { ...DEFAULTS };
73
+ }
74
+ }
package/src/writer.ts ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * pi-logger — JSONL file writer.
3
+ *
4
+ * Writes one JSON line per log entry to per-day files.
5
+ * Directory depends on scope setting:
6
+ * global: ~/.pi/agent/logs/YYYY-MM-DD.jsonl
7
+ * project: .pi/logs/YYYY-MM-DD.jsonl
8
+ *
9
+ * Timestamps are formatted in the configured timezone.
10
+ * Errors are silently swallowed — logging must never break the agent.
11
+ */
12
+
13
+ import * as fs from "node:fs";
14
+ import * as path from "node:path";
15
+ import { getAgentDir } from "@mariozechner/pi-coding-agent";
16
+ import type { LogScope } from "./settings.ts";
17
+
18
+ export interface LogEntry {
19
+ ts: string;
20
+ level: string;
21
+ channel: string;
22
+ event: string;
23
+ data: unknown;
24
+ }
25
+
26
+ /** Resolve the logs directory based on scope. */
27
+ function getLogsDir(scope: LogScope, cwd: string): string {
28
+ if (scope === "project") {
29
+ return path.join(cwd, ".pi", "logs");
30
+ }
31
+ return path.join(getAgentDir(), "logs");
32
+ }
33
+
34
+ /** Format a Date in the given IANA timezone as YYYY-MM-DD. */
35
+ function dateTag(timezone: string): string {
36
+ try {
37
+ // Use sv-SE locale for ISO-ish date part (YYYY-MM-DD)
38
+ const parts = new Intl.DateTimeFormat("sv-SE", {
39
+ timeZone: timezone,
40
+ year: "numeric",
41
+ month: "2-digit",
42
+ day: "2-digit",
43
+ }).formatToParts(new Date());
44
+
45
+ const y = parts.find((p) => p.type === "year")!.value;
46
+ const m = parts.find((p) => p.type === "month")!.value;
47
+ const d = parts.find((p) => p.type === "day")!.value;
48
+ return `${y}-${m}-${d}`;
49
+ } catch {
50
+ return new Date().toISOString().slice(0, 10);
51
+ }
52
+ }
53
+
54
+ /** Format a Date as an ISO-ish timestamp in the given timezone. */
55
+ function formatTimestamp(timezone: string): string {
56
+ try {
57
+ const now = new Date();
58
+ const fmt = new Intl.DateTimeFormat("en-GB", {
59
+ timeZone: timezone,
60
+ year: "numeric",
61
+ month: "2-digit",
62
+ day: "2-digit",
63
+ hour: "2-digit",
64
+ minute: "2-digit",
65
+ second: "2-digit",
66
+ fractionalSecondDigits: 3,
67
+ hour12: false,
68
+ });
69
+ const parts = fmt.formatToParts(now);
70
+ const get = (t: string) => parts.find((p) => p.type === t)?.value ?? "00";
71
+ return `${get("year")}-${get("month")}-${get("day")}T${get("hour")}:${get("minute")}:${get("second")}.${get("fractionalSecond")}`;
72
+ } catch {
73
+ return new Date().toISOString();
74
+ }
75
+ }
76
+
77
+ /** Singleton-ish state to avoid re-creating dirs on every write. */
78
+ let ensuredDir: string | null = null;
79
+
80
+ /**
81
+ * Split a bus event name into channel and event.
82
+ * "log:webserver" → { channel: "log", event: "webserver" }
83
+ * "heartbeat:result" → { channel: "heartbeat", event: "result" }
84
+ * "log" → { channel: "log", event: "" }
85
+ */
86
+ function splitEvent(busEvent: string): { channel: string; event: string } {
87
+ const idx = busEvent.indexOf(":");
88
+ if (idx === -1) return { channel: busEvent, event: "" };
89
+ return { channel: busEvent.slice(0, idx), event: busEvent.slice(idx + 1) };
90
+ }
91
+
92
+ /**
93
+ * Append a log entry to the daily JSONL file.
94
+ * Safe to call at any frequency — writes are synchronous appends.
95
+ */
96
+ export function writeLogEntry(
97
+ busEvent: string,
98
+ level: string,
99
+ data: unknown,
100
+ scope: LogScope,
101
+ cwd: string,
102
+ timezone: string,
103
+ ): void {
104
+ try {
105
+ const dir = getLogsDir(scope, cwd);
106
+ if (ensuredDir !== dir) {
107
+ fs.mkdirSync(dir, { recursive: true });
108
+ ensuredDir = dir;
109
+ }
110
+
111
+ const { channel, event } = splitEvent(busEvent);
112
+
113
+ const entry: LogEntry = {
114
+ ts: formatTimestamp(timezone),
115
+ level,
116
+ channel,
117
+ event,
118
+ data,
119
+ };
120
+
121
+ const file = path.join(dir, `${dateTag(timezone)}.jsonl`);
122
+ fs.appendFileSync(file, JSON.stringify(entry) + "\n", "utf-8");
123
+ } catch {
124
+ // Swallow — logging must never disrupt the agent.
125
+ }
126
+ }