@falling-ts/dsh-force-compact 0.2.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.
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Universal crash net for every exported method entry in the plugin.
3
+ *
4
+ * Two layers:
5
+ * 1. **Entry wrappers** — every exported function routed through
6
+ * {@link guardFn} gets its ENTIRE body (the topmost level of the call
7
+ * tree) covered: on throw, a detailed diagnostic — function name, the exact
8
+ * throw site (`file:line:column`), the deepest plugin frame, the nearest
9
+ * NON-plugin frame (usually the vendored caller that actually faulted),
10
+ * and the full call stack — is appended to the plugin's durable log file.
11
+ * 2. **Process-wide net** ({@link installCrashNet}) — `uncaughtException` +
12
+ * `unhandledRejection` handlers (installed at most once per process) that
13
+ * classify whatever escapes every entry point the same way.
14
+ *
15
+ * Output goes straight to the durable debug log (same destination convention
16
+ * as `core/log.js`: `~/.dsh/logs/dsh-force-compact.log`), bypassing
17
+ * `ctx.logger` entirely so a crash remains reconstructable even before the
18
+ * logger is wired or after a process death. Every emission site is itself
19
+ * self-defensive: a logger failure can never disturb a business path.
20
+ *
21
+ * @module @falling-ts/dsh-force-compact/crashnet
22
+ */
23
+
24
+ import fs from 'node:fs'
25
+ import os from 'node:os'
26
+ import path from 'node:path'
27
+
28
+ /** Expand a leading `~` using the process user home (Windows: USERPROFILE). */
29
+ function expandTilde(value) {
30
+ if (typeof value !== 'string') return value
31
+ const trimmed = value.trim()
32
+ if (trimmed !== '~' && !trimmed.startsWith('~/') && !trimmed.startsWith('~\\')) return value
33
+ const rest = trimmed === '~' ? '' : trimmed.slice(2)
34
+ const home = process.env.USERPROFILE || process.env.HOME || (os.homedir && os.homedir())
35
+ if (home === undefined || home === null || typeof home !== 'string' || home.length === 0) return value
36
+ return path.join(home, rest)
37
+ }
38
+
39
+ /** Durable crash-log path — identical convention to `core/log.js`. */
40
+ function crashLogPath() {
41
+ try {
42
+ return expandTilde('~/.dsh/logs/dsh-force-compact.log')
43
+ } catch {
44
+ return ''
45
+ }
46
+ }
47
+
48
+ /** Append one line to the durable crash log. Never throws. */
49
+ function appendDiag(line) {
50
+ try {
51
+ const p = crashLogPath()
52
+ if (p === '') return
53
+ fs.mkdirSync(path.dirname(p), { recursive: true })
54
+ fs.appendFileSync(p, line + '\n', 'utf8')
55
+ } catch (_diagnosticFailure) {
56
+ // A diagnostic sink must never break a business path.
57
+ }
58
+ }
59
+
60
+ /** True when a stack-frame line points inside this plugin package. */
61
+ function isPluginFrame(frame) {
62
+ return typeof frame === 'string' && frame.indexOf('dsh-force-compact') >= 0
63
+ }
64
+
65
+ /** Extract a human-readable `file:line:col` from one stack-frame line. */
66
+ function frameLocation(frame) {
67
+ if (typeof frame !== 'string') return String(frame)
68
+ const match = /\(?([^(\n]*?)(?:(\d+))?(?:(\d+))?(\))?$/g.exec(frame.trim())
69
+ if (match === null) return frame.trim()
70
+ const file = (match[1] || '').trim()
71
+ const line = match[2]
72
+ const col = match[3]
73
+ if (file === '' && line === undefined) return frame.trim()
74
+ if (line === undefined) return file
75
+ return `${file}:${line}${col === undefined ? '' : ':' + col}`
76
+ }
77
+
78
+ /**
79
+ * Split a stack string into plugin-side and non-plugin frames.
80
+ * @param {string} stackStack the raw `Error.stack` text.
81
+ * @returns {{ pluginFrames: string[], foreignFrames: string[] }}
82
+ */
83
+ function partitionFrames(stackStack) {
84
+ const pluginFrames = []
85
+ const foreignFrames = []
86
+ if (typeof stackStack !== 'string') return { pluginFrames, foreignFrames }
87
+ for (const raw of stackStack.split('\n')) {
88
+ const trimmed = raw.trim()
89
+ if (trimmed.length === 0) continue
90
+ if (/(^|\()dsh-force-compact/.test(trimmed)) pluginFrames.push(trimmed)
91
+ else if (/^at /.test(trimmed)) foreignFrames.push(trimmed)
92
+ }
93
+ return { pluginFrames, foreignFrames }
94
+ }
95
+
96
+ /**
97
+ * Render the multi-line crash diagnostic for one thrown value.
98
+ * @param {string} label stable display name of the wrapping entry.
99
+ * @param {unknown} error the thrown value.
100
+ * @param {string} site the exact `file:line:col` where the throw originated,
101
+ * or the sentinel marker when unavailable.
102
+ * @returns {string[]} formatted lines ready for the durable log.
103
+ */
104
+ export function renderCrash(label, error, site) {
105
+ const isErrorLike = error instanceof Error
106
+ const message = isErrorLike ? (error.message ?? '(no message)') : (typeof error === 'string' ? error : (function stringifySafe(v) { try { return JSON.stringify(v) } catch { return String(v) } })(error))
107
+ const stackString = (isErrorLike && typeof error.stack === 'string' && error.stack.length > 0) ? error.stack : ''
108
+ const { pluginFrames, foreignFrames } = partitionFrames(stackString)
109
+ const lines = []
110
+ lines.push(`[force-compact][CRASHNET] ENTRY FAILURE — ${label}`)
111
+ lines.push(` message: ${message}`)
112
+ lines.push(` thrownAt: ${typeof site === 'string' && site.length > 0 ? site : '(not captured)'}`)
113
+ if (pluginFrames.length > 0) lines.push(` deepest-plugin-frame: ${frameLocation(pluginFrames[pluginFrames.length - 1])}`)
114
+ if (foreignFrames.length > 0) lines.push(` nearest-non-plugin-frame: ${frameLocation(foreignFrames[foreignFrames.length - 1])}`)
115
+ const stackLines = stackString.length > 0 ? stackString.split('\n') : []
116
+ lines.push(` ---- call stack (up to 40 frames) ----`)
117
+ for (const raw of stackLines.slice(0, 40)) lines.push(` ${raw}`)
118
+ if (stackLines.length > 40) lines.push(` …(${stackLines.length - 40} more frames elided)`)
119
+ if (stackLines.length === 0) lines.push(` (no stack captured — the thrown value carried no .stack)`)
120
+ lines.push(` ------------------------------------------`)
121
+ return lines
122
+ }
123
+
124
+ /**
125
+ * Capture the EXACT call site of the current expression: the innermost frame
126
+ * of a freshly minted `Error().stack`. Called from the wrapper's catch block
127
+ * it names `file:line:col` of the statement that threw.
128
+ * @returns {string} the call-site coordinate, or a sentinel on failure.
129
+ */
130
+ function captureSite() {
131
+ try {
132
+ const raw = new Error('crashnet-site-marker').stack
133
+ if (typeof raw !== 'string' || raw.length === 0) return '(no stack available)'
134
+ const lines = raw.split('\n')
135
+ const idx = lines.findIndex(l => l.indexOf('crashnet-site-marker') >= 0)
136
+ if (idx < 0) return '(marker frame not found)'
137
+ return frameLocation(lines[idx])
138
+ } catch {
139
+ return '(site capture failed)'
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Wrap a method entry with the universal crash net.
145
+ *
146
+ * Semantics:
147
+ * - **Success path**: synchronous bodies resolve synchronously; asynchronous
148
+ * (promise-returning) bodies keep their promise shape. Callers observe
149
+ * exactly what they observe today.
150
+ * - **Failure path**: the wrapper appends the full diagnostic to the durable
151
+ * log AND rethrows/propagates the ORIGINAL error (or promise rejection).
152
+ * Existing `try/catch` blocks in callers keep receiving it — this layer adds
153
+ * observability, it changes no control flow.
154
+ *
155
+ * @template {(...args:any)=>any} F
156
+ * @param {string} label stable display name of the wrapped entry.
157
+ * @param {F} fn the body to cover.
158
+ * @returns {F} the covered body.
159
+ */
160
+ export function guardFn(label, fn) {
161
+ const wrapped = (...args) => {
162
+ try {
163
+ const result = fn(...args)
164
+ if (result !== null && typeof result === 'object' && typeof result.catch === 'function') {
165
+ // Async entry: attach a rejection observer that LOGS but PRESERVES the
166
+ // rejection (downstream catches still fire). The `.catch` chain returns
167
+ // a new promise; callers awaiting it see the identical outcome.
168
+ result.catch((error) => {
169
+ for (const line of renderCrash(`${label} (async)`, error, captureSite())) appendDiag(line)
170
+ })
171
+ return result
172
+ }
173
+ return result
174
+ } catch (error) {
175
+ for (const line of renderCrash(label, error, captureSite())) appendDiag(line)
176
+ throw error
177
+ }
178
+ }
179
+ try { Object.defineProperty(wrapped, 'name', { value: label, configurable: true }) } catch { /* cosmetic */ }
180
+ return wrapped
181
+ }
182
+
183
+ /** Guarded installation flag — at most one set of process handlers. */
184
+ let installed = false
185
+
186
+ /**
187
+ * Install the process-wide crash net (idempotent).
188
+ *
189
+ * Attaches one `uncaughtException` handler and one `unhandledRejection`
190
+ * handler that emit a full diagnostic for anything escaping every wrapped
191
+ * entry (native callbacks, microtask roots, timer callbacks). The exception
192
+ * handler KEEPS THE PROCESS ALIVE (Node's default terminates on
193
+ * `uncaughtException`), so the diagnostic line lands before any later
194
+ * supervisor decision. If a different subsystem installs its own handler
195
+ * earlier, ours still fires — multiple handlers compose.
196
+ */
197
+ export function installCrashNet() {
198
+ if (installed) return
199
+ installed = true
200
+ process.on('uncaughtException', (error) => {
201
+ for (const line of renderCrash('UNCAUGHT EXCEPTION (process-wide net)', error, captureSite())) appendDiag(line)
202
+ })
203
+ process.on('unhandledRejection', (reason) => {
204
+ for (const line of renderCrash('UNHANDLED REJECTION (process-wide net)', reason, captureSite())) appendDiag(line)
205
+ })
206
+ }
207
+
208
+ /** Public accessor: append one line to the durable crash log. */
209
+ export function appendCrashLine(line) { appendDiag(line) }
210
+
211
+ /** Public accessor: capture the current call-site coordinates. */
212
+ export function captureThrowSite() { return captureSite() }
213
+
214
+ /** Public accessor: the resolved crash-log path. */
215
+ export function getCrashLogPath() { return crashLogPath() }
@@ -0,0 +1,346 @@
1
+ /**
2
+ * dsh-force-compact debug log sink.
3
+ *
4
+ * Installs a `ctx.logger` exporter that routes **this plugin's own** log lines
5
+ * (those whose first argument is tagged `[force-compact]`) to a file — by
6
+ * default `~/.dsh/logs/dsh-force-compact.log` (under the shared user `$DSH_HOME`,
7
+ * kept out of any single checkout) — whenever debug logging is enabled by the
8
+ * `falling-ts-force-compact` settings (`debug`, a boolean that defaults to
9
+ * `true`). This makes the plugin's otherwise-invisible `warn` / `debug`
10
+ * diagnostics land somewhere durable, closing the loop where the stock
11
+ * logger's in-memory-only default sink + `INFO` floor meant those lines went
12
+ * nowhere.
13
+ *
14
+ * Installation is deferred to {@linkcode ensureDebugLogger}, invoked lazily from
15
+ * the guarded listeners. File I/O goes through **Node's native `node:fs`**
16
+ * (dynamically imported), not the product `fs` service — the latter is fenced
17
+ * by the sandbox policy to the session workspace and refuses an absolute path
18
+ * such as `~/.dsh/logs` (which is deliberately kept OUT of any workspace). A
19
+ * diagnostic side-channel to the user home is therefore best served straight
20
+ * from Node, independent of the instance's sandbox mode.
21
+ *
22
+ * Design notes (kept deliberately minimal, per this plugin's conventions):
23
+ * - No `timer` and no long-lived in-memory queue: each captured line performs
24
+ * a single fire-and-forget read-append of the file. Occasional loss or
25
+ * interleaving under concurrent flushes is acceptable for a diagnostic sink
26
+ * and never affects a request path.
27
+ * - The exporter registers with `levels: { default: DEBUG }` so the host's
28
+ * default `INFO` floor no longer drops `warn` / `debug`.
29
+ * - Appends are capped (~{@linkcode MAX_LOG_CHARS}) keeping the tail, so the
30
+ * file cannot grow unbounded.
31
+ * - Every failure is swallowed: a diagnostic sink must never break business
32
+ * paths.
33
+ *
34
+ * @module @falling-ts/dsh-force-compact/debug-log
35
+ */
36
+
37
+ import { readSettings, DEFAULTS, NS } from './settings.js'
38
+
39
+ /** Marker identifying this plugin's own log lines (matches every `ctx.logger.*('[force-compact] …')` call site). */
40
+ const MARKER = '[force-compact]'
41
+
42
+ /**
43
+ * Expand a leading `~` prefix to the absolute OS user home, so `fs.resolve`
44
+ * (which treats `~` as a literal path segment) receives an absolute path.
45
+ *
46
+ * Reuses the harness's own `expandHomePath` (from the resolvable
47
+ * `@deepseek-ai/dsh-home-paths` workspace package, whose `homedir()` honors
48
+ * `USERPROFILE` on Windows) when importable; otherwise falls back to a local
49
+ * expansion driven by whichever user-home environment variable is readable
50
+ * (`USERPROFILE` on Windows, `HOME` elsewhere) without requiring Node globals
51
+ * to be injected. Returns the input unchanged when no supported `~` prefix is
52
+ * present or no home can be determined.
53
+ *
54
+ * @param {string} path
55
+ * @returns {Promise<string>}
56
+ */
57
+ async function expandHome(path) {
58
+ if (typeof path !== 'string') return path
59
+ const trimmed = path.trim()
60
+ if (trimmed !== '~' && !trimmed.startsWith('~/') && !trimmed.startsWith('~\\')) return path
61
+ const rest = trimmed === '~' ? '' : trimmed.slice(2)
62
+ try {
63
+ const mod = await import('@deepseek-ai/dsh-home-paths')
64
+ const fn = mod.expandHomePath
65
+ if (typeof fn === 'function') return fn(trimmed)
66
+ } catch {
67
+ // Fall through to the local expansion below.
68
+ }
69
+ // Last resort: read the user home from the environment via a dynamically
70
+ // imported `node:os` (reachable from the plugin's loader base), and expand
71
+ // manually if that works too. When none is available, return the input as-is
72
+ // so the caller still attempts a best-effort write rather than throwing.
73
+ const home = await readUserHome()
74
+ if (home === null) return path
75
+ const sep = home.includes('\\') ? '\\' : '/'
76
+ return home + (rest === '' ? '' : sep + rest.replace(/\\/g, sep))
77
+ }
78
+
79
+ /**
80
+ * Best-effort OS user home without relying on injected globals: prefer an
81
+ * explicitly-readable home (via dynamically imported `node:os`), else `null`.
82
+ *
83
+ * @returns {Promise<string|null>}
84
+ */
85
+ async function readUserHome() {
86
+ try {
87
+ const mod = await import('node:os')
88
+ const fn = mod && mod.homedir
89
+ if (typeof fn === 'function') {
90
+ const h = fn.call(mod)
91
+ if (typeof h === 'string' && h.length > 0) return h
92
+ }
93
+ } catch {
94
+ // `node:os` unreachable; leave it to the caller.
95
+ }
96
+ return null
97
+ }
98
+
99
+ /** Cap the on-disk log size (characters), keeping the most recent lines. */
100
+ const MAX_LOG_CHARS = 1000000
101
+
102
+ /**
103
+ * Install the debug-log sink for this plugin, **at most once**, called lazily
104
+ * from the guarded listeners.
105
+ *
106
+ * Idempotency: a process-local latch (`debugState`) settles the outcome. On a
107
+ * committed install `installed` becomes `true` and every subsequent call is a
108
+ * cheap early return (no settings read). While a prior attempt has not settled
109
+ * (`attempted` false) a later listener re-enters. The first successful install
110
+ * wins and binds the exporter to its fiber, where it is disposed automatically
111
+ * when the plugin stops, updates, or is removed.
112
+ *
113
+ * Reads the `falling-ts-force-compact` settings live (`debug`, default `true`,
114
+ * and `logFile`, default `~/.dsh/logs/dsh-force-compact.log`), expands a
115
+ * leading `~` to the absolute OS home, and registers a `ctx.logger` exporter
116
+ * that routes only this plugin's own `[force-compact]` lines to the file via
117
+ * native `node:fs` (sandbox-independent). Swallows all failures: a diagnostic
118
+ * sink must never break business paths.
119
+ *
120
+ * @param {import('@deepseek-ai/cordis').Context} ctx
121
+ * @returns {Promise<void>}
122
+ */
123
+ export async function ensureDebugLogger(ctx) {
124
+ if (debugState.installed) return
125
+ if (debugState.attempted) return
126
+ debugState.attempted = true
127
+
128
+ // SAFETY ENVELOPE: a diagnostic-sink installer must NEVER break a business
129
+ // path. Wrap the whole install so any anomaly (a throwing
130
+ // `ctx.logger.exporter`, a rejecting settings read, a bad path) marks the
131
+ // sink as settled-installed and moves on silently rather than propagating.
132
+ try {
133
+ await __ensureDebugLoggerBody(ctx)
134
+ } catch {
135
+ debugState.installed = true
136
+ }
137
+ }
138
+
139
+ async function __ensureDebugLoggerBody(ctx) {
140
+ const resolved = (await readSettings(ctx)) ?? { ...DEFAULTS }
141
+ // Centralize the `debug` gate at the EXPORT boundary: the exporter decides,
142
+ // per line, whether to persist — reading the live `debug` setting at install
143
+ // time (and, on the fast path, trusting the cached flag). A `debug === false`
144
+ // deployment therefore installs nothing and writes nothing: no self-noticing
145
+ // line, no settings round-trip per emitted line.
146
+ if (resolved.debug !== true) {
147
+ debugState.installed = true
148
+ return
149
+ }
150
+ if (!resolved.logFile) {
151
+ ctx.logger.warn('[force-compact] debug logging enabled but no log file path configured — nothing will be written')
152
+ debugState.installed = true
153
+ return
154
+ }
155
+
156
+ const filePath = await expandHome(resolved.logFile)
157
+ if (!filePath) {
158
+ ctx.logger.warn('[force-compact] debug logging enabled but no log file path could be resolved — nothing will be written')
159
+ debugState.installed = true
160
+ return
161
+ }
162
+
163
+ // Take a SINGLE synchronous snapshot of the `debug` setting on the very first
164
+ // exported line (cheap `getSync`/`get`, cached forever-after). This makes the
165
+ // export-boundary gate race-free: `debug === false` deployments persist
166
+ // NOTHING even for the first line, because the snapshot resolves synchronously
167
+ // rather than deferring to a background read. Thereafter the cached boolean
168
+ // drives the gate at zero per-line cost; a mid-process `debug` flip takes
169
+ // effect on the next exporter reinstall.
170
+ let debugGate
171
+ const exporter = {
172
+ colors: false,
173
+ levels: { default: 3 },
174
+ export: (message) => {
175
+ // Final persistence gate lives HERE (the export boundary): a line is
176
+ // written iff it carries the plugin marker AND the `debug` setting is on.
177
+ if (!shouldInclude(message)) return
178
+ if (debugGate === undefined) debugGate = syncDebugSettingSnapshot(ctx)
179
+ if (!debugGate) return
180
+ void writeLine(filePath, renderLine(message))
181
+ },
182
+ }
183
+
184
+ // Bound to the current fiber; removed on stop/update/undefine.
185
+ ctx.logger.exporter(exporter)
186
+ debugState.installed = true
187
+ // Logged AFTER the exporter is installed so this notice lands in the file too.
188
+ ctx.logger.info(
189
+ `[force-compact] debug logging enabled — writing [force-compact] lines to ${filePath}`,
190
+ )
191
+ }
192
+
193
+ /**
194
+ * Process-local install state for the debug sink: `installed` permanently stops
195
+ * further attempts after a settled outcome; `attempted` prevents re-entering the
196
+ * settings read once a decision has begun (without committing the exporter yet).
197
+ * Module scope, never exported; reset naturally on a fresh process.
198
+ */
199
+ const debugState = { attempted: false, installed: false }
200
+
201
+ /**
202
+ * Synchronously snapshot the live `debug` setting exactly once (module-level
203
+ * cache), resolving either through the `settings` service's synchronous read
204
+ * (`getSync`) or its ordinary read (returned synchronously when backed by a
205
+ * local store). Returns a settled boolean (never throws): missing values fall
206
+ * back to the composition default. Settling here at the FIRST exported line
207
+ * keeps the steady-state `export()` path free of any settings round-trip; a
208
+ * mid-process `debug` flip takes effect on the next exporter reinstall.
209
+ */
210
+ let cachedDebug
211
+ function syncDebugSettingSnapshot(ctx) {
212
+ if (cachedDebug !== undefined) return cachedDebug
213
+ try {
214
+ const raw = ctx.get?.('settings')
215
+ let v
216
+ if (raw != null && typeof raw.getSync === 'function') {
217
+ v = raw.getSync(NS)?.debug
218
+ } else if (typeof raw?.get === 'function') {
219
+ v = raw.get(NS)?.debug
220
+ }
221
+ return v !== undefined ? v === true : DEFAULTS.debug
222
+ } catch {
223
+ return DEFAULTS.debug
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Keep only this plugin's own lines: the first `args` element is the log
229
+ * template string and must carry the `[force-compact]` marker.
230
+ *
231
+ * @param {any} message structured log record
232
+ * @returns {boolean}
233
+ */
234
+ function shouldInclude(message) {
235
+ if (message === null || typeof message !== 'object') return false
236
+ const args = message.args
237
+ if (!Array.isArray(args) || args.length === 0) return false
238
+ const first = args[0]
239
+ return typeof first === 'string' && first.indexOf(MARKER) !== -1
240
+ }
241
+
242
+ /**
243
+ * Render one message to a single prefixed line: ISO timestamp, severity tag,
244
+ * then the formatted arguments. Errors contribute their stack (or message);
245
+ * objects are compacted to JSON; everything else is stringified. Only the
246
+ * shallow `args` are touched — the live record itself is never cloned or
247
+ * recursively enumerated.
248
+ *
249
+ * @param {any} message
250
+ * @returns {string}
251
+ */
252
+ function renderLine(message) {
253
+ // Tolerate a malformed `message`: a non-date `ts` (undefined/null/nonsense
254
+ // string) would otherwise make `new Date(...).toISOString()` throw on the
255
+ // `Invalid Date` value. Degrade to an epoch-zero timestamp so the line still
256
+ // renders — a diagnostic line dropping on a weird record is unacceptable.
257
+ let ts
258
+ try {
259
+ ts = Number.isFinite(+new Date(message.ts).getTime()) ? new Date(message.ts).toISOString() : new Date(0).toISOString()
260
+ } catch {
261
+ ts = new Date(0).toISOString()
262
+ }
263
+ const type = String(message && message.type !== undefined ? message.type : 'info').toUpperCase()
264
+ return `${ts} [${type}] ${formatArgs(Array.isArray(message && message.args) ? message.args : [])}`
265
+ }
266
+
267
+ /**
268
+ * Format the shallow argument list into a printable string.
269
+ *
270
+ * @param {any[]} args
271
+ * @returns {string}
272
+ */
273
+ function formatArgs(args) {
274
+ const parts = []
275
+ for (let i = 0; i < args.length; i++) {
276
+ const arg = args[i]
277
+ if (arg instanceof Error) parts.push(arg.stack || arg.message)
278
+ else if (typeof arg === 'object' && arg !== null) {
279
+ try {
280
+ parts.push(JSON.stringify(arg))
281
+ } catch {
282
+ parts.push(String(arg))
283
+ }
284
+ } else {
285
+ parts.push(String(arg))
286
+ }
287
+ }
288
+ return parts.join(' ')
289
+ }
290
+
291
+ /**
292
+ * Lazily import Node's native filesystem promises, cached after the first load.
293
+ * Native `node:fs` is intentionally chosen over the product `fs` service because
294
+ * the latter is fenced by the sandbox policy to the session workspace and
295
+ * refuses an absolute user-home path; the native module writes the absolute
296
+ * target directly, independent of sandbox mode. Returns `null` if the import
297
+ * ever fails (extremely unlikely for a builtin), letting callers degrade.
298
+ *
299
+ * @returns {Promise<Object|null>}
300
+ */
301
+ let nativeFsPromises
302
+ async function getNodeFs() {
303
+ if (nativeFsPromises !== undefined) return nativeFsPromises
304
+ try {
305
+ nativeFsPromises = await import('node:fs/promises')
306
+ } catch {
307
+ nativeFsPromises = null
308
+ }
309
+ return nativeFsPromises
310
+ }
311
+
312
+ /**
313
+ * Append one line to the debug log using native Node `fs`. Creates the parent
314
+ * directory if needed, appends, and truncates to {@linkcode MAX_LOG_CHARS}
315
+ * keeping the tail. Fire-and-forget; swallows all errors so a diagnostic sink
316
+ * can never disturb a business path.
317
+ *
318
+ * @param {string} path absolute target path
319
+ * @param {string} line rendered line WITHOUT a trailing newline
320
+ */
321
+ async function writeLine(path, line) {
322
+ try {
323
+ const fsp = await getNodeFs()
324
+ if (fsp === null) return
325
+ const { dirname } = await import('node:path')
326
+ // Ensure the containing directory exists (recursive, no-op if present).
327
+ await fsp.mkdir(dirname(path), { recursive: true })
328
+ await fsp.appendFile(path, line + '\n')
329
+ // Tail-cap: if the file grew past the budget, keep only the last
330
+ // MAX_LOG_CHARS characters so it cannot grow unbounded.
331
+ const stat = await fsp.stat(path).catch(() => null)
332
+ if (stat !== null && stat.size > MAX_LOG_CHARS) {
333
+ const handle = await fsp.open(path, 'r+')
334
+ try {
335
+ const buffer = Buffer.alloc(MAX_LOG_CHARS)
336
+ const { bytesRead } = await handle.readFile(buffer, 0, MAX_LOG_CHARS, stat.size - MAX_LOG_CHARS)
337
+ await handle.writeFile(Buffer.from(buffer.subarray(0, bytesRead)), 0, bytesRead, 0)
338
+ await handle.truncate(bytesRead)
339
+ } finally {
340
+ await handle.close()
341
+ }
342
+ }
343
+ } catch {
344
+ // Diagnostic sink must never break the requesting path.
345
+ }
346
+ }