@hope_phenom/dsh-plugin-remote-use 0.0.0-stage → 0.2.24

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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +207 -2
  3. package/README.zh.md +195 -0
  4. package/assets/icon.svg +47 -0
  5. package/cordis.patch.yml +41 -0
  6. package/lib/bridge/acp-bridge.js +905 -0
  7. package/lib/bridge/peer-connection.js +730 -0
  8. package/lib/client.js +2842 -0
  9. package/lib/core/acp-model-overlay.js +121 -0
  10. package/lib/core/approval-queue.js +216 -0
  11. package/lib/core/audit-log.js +322 -0
  12. package/lib/core/desktop-notify.js +173 -0
  13. package/lib/core/environment-prompt.js +76 -0
  14. package/lib/core/relay-supervisor.js +278 -0
  15. package/lib/core/review-engine.js +679 -0
  16. package/lib/core/sandbox-diagnosis.js +258 -0
  17. package/lib/core/sandbox-probe.js +310 -0
  18. package/lib/core/self-acp-spec.js +117 -0
  19. package/lib/core/selfcheck-runner.js +150 -0
  20. package/lib/core/settings-store.js +226 -0
  21. package/lib/core/shot-gc.js +334 -0
  22. package/lib/core/task-cwd.js +50 -0
  23. package/lib/core/task-memory.js +163 -0
  24. package/lib/core/task-registry.js +238 -0
  25. package/lib/core/toolcall-cache.js +206 -0
  26. package/lib/core/transfer-queue.js +58 -0
  27. package/lib/core/transfer.js +1144 -0
  28. package/lib/core/worker-arming.js +334 -0
  29. package/lib/index.js +2207 -0
  30. package/lib/provider/out-of-process-helpers.js +76 -0
  31. package/lib/provider/remote-provider.js +261 -0
  32. package/lib/tools/mediator-shim.mjs +180 -0
  33. package/lib/tools/prepare-sandbox-mediator.mjs +529 -0
  34. package/lib/tools/remote-tools.js +680 -0
  35. package/lib/tools/selfcheck-fix-prompt.md +206 -0
  36. package/locale/en.json +6 -0
  37. package/locale/zh.json +6 -0
  38. package/package.json +72 -4
  39. package/slave/acp-profile-approval.patch.yml +66 -0
  40. package/slave/file-transfer.mjs +552 -0
  41. package/slave/integrity-probe.ps1 +99 -0
  42. package/slave/make-launch-spec.mjs +184 -0
  43. package/slave/net-address.mjs +138 -0
  44. package/slave/node_modules/.package-lock.json +26 -0
  45. package/slave/node_modules/ws/LICENSE +20 -0
  46. package/slave/node_modules/ws/README.md +548 -0
  47. package/slave/node_modules/ws/browser.js +8 -0
  48. package/slave/node_modules/ws/index.js +22 -0
  49. package/slave/node_modules/ws/lib/buffer-util.js +131 -0
  50. package/slave/node_modules/ws/lib/constants.js +19 -0
  51. package/slave/node_modules/ws/lib/event-target.js +292 -0
  52. package/slave/node_modules/ws/lib/extension.js +203 -0
  53. package/slave/node_modules/ws/lib/limiter.js +55 -0
  54. package/slave/node_modules/ws/lib/permessage-deflate.js +530 -0
  55. package/slave/node_modules/ws/lib/receiver.js +743 -0
  56. package/slave/node_modules/ws/lib/sender.js +607 -0
  57. package/slave/node_modules/ws/lib/stream.js +161 -0
  58. package/slave/node_modules/ws/lib/subprotocol.js +62 -0
  59. package/slave/node_modules/ws/lib/validation.js +152 -0
  60. package/slave/node_modules/ws/lib/websocket-server.js +562 -0
  61. package/slave/node_modules/ws/lib/websocket.js +1421 -0
  62. package/slave/node_modules/ws/package.json +74 -0
  63. package/slave/node_modules/ws/wrapper.mjs +21 -0
  64. package/slave/pairing.mjs +175 -0
  65. package/slave/permission-pairing.mjs +240 -0
  66. package/slave/prune-shots.mjs +170 -0
  67. package/slave/relay.mjs +1351 -0
  68. package/slave/repair-workspace-integrity.ps1 +75 -0
  69. package/slave/screenshot.ps1 +114 -0
  70. package/slave/selfcheck.ps1 +350 -0
  71. package/slave/stage-app.ps1 +116 -0
  72. package/slave/workspace-integrity.mjs +157 -0
@@ -0,0 +1,121 @@
1
+ /**
2
+ * The worker's ACP session model, applied as a plugin-owned `--patch` overlay at launch.
3
+ *
4
+ * WHY A MODEL PIN AT ALL: `dsh-acp`'s `initialize` advertises `promptCapabilities.image` from the
5
+ * CONFIGURED route (`supportsAcpImagePrompts(ctx, provider, model)`), and a wrong or missing id
6
+ * turns image prompts off silently -- also breaking `read_image` inside the worker's own agent
7
+ * (DESIGN 14.1). The capability cannot be switched per session afterwards: the flag is fixed for
8
+ * the connection, so the value has to be in the launch configuration.
9
+ *
10
+ * WHY AN OVERLAY AND NOT A PROFILE EDIT: the plugin's rule is that the operator's profile belongs
11
+ * to the operator (see the sandbox-mediator work). `--patch` is applied AFTER the profile layer, so
12
+ * this file wins without touching `cordis.patch.yml`; clearing the setting simply stops passing
13
+ * it. The trade-off is that the value is not visible in a bare `--dump-config` -- it only appears
14
+ * when the same overlay is passed, which is what the plugin does at launch. The setting itself is
15
+ * shown on the worker's settings page, and the master can see the effect in `initialize`.
16
+ *
17
+ * The file is a YAML LIST of loader patch entries, like every other patch layer.
18
+ */
19
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
20
+ import { join } from 'node:path'
21
+
22
+ import { resolveDshHome } from './settings-store.js'
23
+
24
+ /** Name under the plugin's storage directory. */
25
+ export const MODEL_OVERLAY_FILENAME = 'acp-model-overlay.yml'
26
+
27
+ export const MODEL_OVERLAY_HEADER = '# --- dsh-plugin-remote-use managed: worker model (generated from this machine\'s "workerModel" setting) ---'
28
+ export const MODEL_OVERLAY_FOOTER = '# --- end dsh-plugin-remote-use managed: worker model ---'
29
+
30
+ /** @param {string} [home] */
31
+ export function workerModelOverlayPath(home = resolveDshHome()) {
32
+ return join(home, 'storages', 'dsh-plugin-remote-use', MODEL_OVERLAY_FILENAME)
33
+ }
34
+
35
+ /**
36
+ * Parse the `workerModel` setting, which is `provider/model`.
37
+ *
38
+ * The FIRST slash separates provider from model: model ids may legitimately contain slashes
39
+ * (Hugging-Face-style ids), provider ids do not. Newlines are refused because this value becomes
40
+ * a YAML scalar; whitespace inside the model is refused for the same reason.
41
+ *
42
+ * @param {string} [value]
43
+ * @returns {{ ok: boolean, provider: string, model: string, reason: string }}
44
+ */
45
+ export function parseWorkerModel(value) {
46
+ const text = String(value ?? '').trim()
47
+ if (text === '') return { ok: true, provider: '', model: '', reason: '' }
48
+ if (/[\r\n]/u.test(text)) return { ok: false, provider: '', model: '', reason: 'must be a single line' }
49
+ const slash = text.indexOf('/')
50
+ if (slash <= 0) return { ok: false, provider: '', model: '', reason: 'expected "provider/model"' }
51
+ const provider = text.slice(0, slash).trim()
52
+ const model = text.slice(slash + 1).trim()
53
+ if (!/^[A-Za-z0-9._-]+$/u.test(provider)) {
54
+ return { ok: false, provider: '', model: '', reason: 'the provider id has unexpected characters' }
55
+ }
56
+ if (model === '' || /\s/u.test(model)) {
57
+ return { ok: false, provider: '', model: '', reason: 'the model id is empty or contains whitespace' }
58
+ }
59
+ return { ok: true, provider, model, reason: '' }
60
+ }
61
+
62
+ /**
63
+ * The overlay text for one model. JSON string quoting is valid YAML and survives every id.
64
+ *
65
+ * @param {{ provider: string, model: string }} opts
66
+ * @returns {string}
67
+ */
68
+ export function renderWorkerModelOverlay({ provider, model }) {
69
+ return [
70
+ MODEL_OVERLAY_HEADER,
71
+ '- id: acp',
72
+ " name: '@deepseek-ai/dsh-acp'",
73
+ ' config:',
74
+ ` provider: ${JSON.stringify(provider)}`,
75
+ ` model: ${JSON.stringify(model)}`,
76
+ MODEL_OVERLAY_FOOTER,
77
+ '',
78
+ ].join('\n')
79
+ }
80
+
81
+ /**
82
+ * Write (or remove) the overlay for the configured value.
83
+ *
84
+ * EMPTY REMOVES IT: an operator who clears the setting must not leave a stale file that someone
85
+ * later passes by hand, and an overlay that is not passed is harmless but confusing.
86
+ *
87
+ * NO BOM, and only written when the content actually changes: the file is tiny, but rewriting it
88
+ * on every launch would still make its mtime meaningless for "what did the last arm use".
89
+ *
90
+ * @param {{ home?: string, value?: string }} [opts]
91
+ * @returns {{ ok: boolean, path: string, written: boolean, reason: string }}
92
+ */
93
+ export function writeWorkerModelOverlay(opts = {}) {
94
+ const home = opts.home ?? resolveDshHome()
95
+ const parsed = parseWorkerModel(opts.value)
96
+ if (parsed.ok !== true) return { ok: false, path: '', written: false, reason: parsed.reason }
97
+ const file = workerModelOverlayPath(home)
98
+ if (parsed.provider === '') {
99
+ if (existsSync(file)) {
100
+ try {
101
+ rmSync(file, { force: true })
102
+ } catch (error) {
103
+ return { ok: false, path: '', written: false, reason: `could not remove ${file}: ${error instanceof Error ? error.message : String(error)}` }
104
+ }
105
+ }
106
+ return { ok: true, path: '', written: false, reason: '' }
107
+ }
108
+ const text = renderWorkerModelOverlay(parsed)
109
+ let previous = ''
110
+ try {
111
+ if (existsSync(file)) previous = readFileSync(file, 'utf8')
112
+ } catch { /* unreadable -> rewrite */ }
113
+ if (previous === text) return { ok: true, path: file, written: false, reason: '' }
114
+ try {
115
+ mkdirSync(join(home, 'storages', 'dsh-plugin-remote-use'), { recursive: true })
116
+ writeFileSync(file, text, 'utf8')
117
+ } catch (error) {
118
+ return { ok: false, path: '', written: false, reason: `could not write ${file}: ${error instanceof Error ? error.message : String(error)}` }
119
+ }
120
+ return { ok: true, path: file, written: true, reason: '' }
121
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * A holding area for approvals the review engine refuses to decide on its own.
3
+ *
4
+ * WHY IT EXISTS: `decideApproval` marks `danger-full-access` (and anything else it cannot
5
+ * justify) as `needsHuman`. Until now that meant an immediate refusal, so a task that
6
+ * genuinely needed a wider scope could never be completed -- there was no way to say yes.
7
+ * This queue turns "needs a human" into "waits for a human, then times out as a refusal".
8
+ *
9
+ * FAIL CLOSED IS THE DEFAULT, NOT AN AFTERTHOUGHT:
10
+ * * an entry that nobody answers settles as `reject-once` when its budget expires;
11
+ * * `dispose()` settles everything still pending as `reject-once`, so a plugin unload
12
+ * cannot leave a request hanging until the bridge's own timeout;
13
+ * * an unknown or already-settled id is not decidable, and `decide()` reports that
14
+ * instead of silently succeeding.
15
+ *
16
+ * The budget is deliberately SHORTER than the bridge's `approvalTimeoutMs`: the bridge also
17
+ * races the callback against its own timer, and if that fires first the queue would keep a
18
+ * ghost entry that no human can ever act on. Settling here first means the recorded outcome
19
+ * is always the one the worker actually received.
20
+ */
21
+
22
+ /** How much earlier than the bridge's timeout this queue gives up. */
23
+ export const QUEUE_LEAD_MS = 2_000
24
+
25
+ /** Settled entries kept for the page's "recent decisions" list. */
26
+ const HISTORY_LIMIT = 50
27
+
28
+ /** How much of a tool call's arguments the page shows. Enough to judge, short of a dump. */
29
+ const RAW_INPUT_LIMIT = 400
30
+
31
+ /** One entry as the PAGE sees it: no resolver, no unbounded payload. */
32
+ function publicEntry(entry) {
33
+ return {
34
+ id: entry.id,
35
+ peerId: entry.peerId,
36
+ taskId: entry.taskId,
37
+ toolCallId: entry.toolCallId,
38
+ toolName: entry.toolName,
39
+ requiredTier: entry.requiredTier,
40
+ // What the WORKER asked for, when it asked explicitly. `requiredTier` is the plugin's own
41
+ // inference of the minimum that would do, and the two disagree in exactly the case that
42
+ // matters: a request for the unrestricted tier showed as "needs workspace-write" on the very
43
+ // row a human was deciding (PITFALLS F22).
44
+ requestedTier: entry.requestedTier,
45
+ reason: entry.reason,
46
+ source: entry.source,
47
+ createdAt: entry.createdAt,
48
+ ageMs: entry.ageMs,
49
+ rawInput: entry.rawInput,
50
+ }
51
+ }
52
+
53
+ /** Truncate a tool call's arguments so the page cannot be handed an unbounded blob. */
54
+ function summarizeRawInput(rawInput) {
55
+ if (rawInput === undefined || rawInput === null) return ''
56
+ let text
57
+ try {
58
+ text = typeof rawInput === 'string' ? rawInput : JSON.stringify(rawInput)
59
+ } catch {
60
+ return '(unserialisable)'
61
+ }
62
+ if (text === undefined) return ''
63
+ return text.length > RAW_INPUT_LIMIT ? `${text.slice(0, RAW_INPUT_LIMIT)}…` : text
64
+ }
65
+
66
+ export class ApprovalQueue {
67
+ /**
68
+ * @param {object} [opts]
69
+ * @param {() => number} [opts.now]
70
+ * @param {number} [opts.timeoutMs] how long one request may wait before it is refused
71
+ * @param {(entry: object) => void} [opts.onSettle] audit hook; called once per entry
72
+ */
73
+ constructor(opts = {}) {
74
+ this.now = opts.now ?? (() => Date.now())
75
+ this.timeoutMs = Number.isFinite(opts.timeoutMs) ? Math.max(1, Number(opts.timeoutMs)) : 178_000
76
+ this.onSettle = typeof opts.onSettle === 'function' ? opts.onSettle : () => {}
77
+ this.disposed = false
78
+ this.seq = 0
79
+ /** @type {Map<string, object>} entries still waiting, holding their resolvers */
80
+ this.pending = new Map()
81
+ /** @type {object[]} settled outcomes, newest last */
82
+ this.history = []
83
+ }
84
+
85
+ /** Entries the page may act on. */
86
+ list() {
87
+ const now = this.now()
88
+ return [...this.pending.values()]
89
+ .map((entry) => publicEntry({ ...entry, ageMs: Math.max(0, now - entry.createdAt) }))
90
+ .sort((a, b) => a.createdAt - b.createdAt)
91
+ }
92
+
93
+ /** Settled outcomes, newest first. */
94
+ recent(limit = 20) {
95
+ return this.history.slice(-Math.max(0, limit)).reverse()
96
+ }
97
+
98
+ get pendingCount() {
99
+ return this.pending.size
100
+ }
101
+
102
+ /**
103
+ * Hold one approval request open.
104
+ *
105
+ * @param {{ peerId: string, taskId?: string, toolCallId?: string, toolName?: string,
106
+ * rawInput?: unknown, requiredTier?: string, reason?: string, source?: string }} input
107
+ * @returns {Promise<'allow-once'|'reject-once'>} the option a human chose, or a refusal
108
+ */
109
+ request(input) {
110
+ if (this.disposed) return Promise.resolve('reject-once')
111
+
112
+ this.seq += 1
113
+ const id = `ap${this.seq}-${this.now().toString(36)}`
114
+ const entry = {
115
+ id,
116
+ peerId: String(input?.peerId ?? ''),
117
+ taskId: String(input?.taskId ?? ''),
118
+ toolCallId: String(input?.toolCallId ?? ''),
119
+ toolName: String(input?.toolName ?? ''),
120
+ requiredTier: String(input?.requiredTier ?? ''),
121
+ requestedTier: String(input?.requestedTier ?? ''),
122
+ reason: String(input?.reason ?? ''),
123
+ source: String(input?.source ?? ''),
124
+ rawInput: summarizeRawInput(input?.rawInput),
125
+ createdAt: this.now(),
126
+ timer: undefined,
127
+ resolve: undefined,
128
+ settled: false,
129
+ }
130
+
131
+ const promise = new Promise((resolve) => {
132
+ entry.resolve = resolve
133
+ entry.timer = setTimeout(() => {
134
+ this.#settle(id, 'reject-once', 'timeout')
135
+ }, this.timeoutMs)
136
+ // Never hold the process open for a request nobody may answer.
137
+ if (typeof entry.timer?.unref === 'function') entry.timer.unref()
138
+ })
139
+
140
+ this.pending.set(id, entry)
141
+ return promise
142
+ }
143
+
144
+ /**
145
+ * Apply a decision made outside the queue.
146
+ *
147
+ * `by` records WHERE the decision came from, and it is not decoration: the settings page calls
148
+ * this through the same route a script would, so labelling every call `human` made the audit
149
+ * claim human involvement for approvals a harness had granted. Measured 2026-10-14: the only
150
+ * `allow-once` row in the live trail came from this repo's own probe, and it read `by=human`.
151
+ *
152
+ * The default is `api` -- the conservative label. A caller that cannot prove a person clicked
153
+ * must not be recorded as one.
154
+ *
155
+ * @param {string} id
156
+ * @param {'allow'|'deny'} decision
157
+ * @param {'page'|'api'} [by] `page` only for a request that carried a browser Origin header
158
+ * @returns {{ ok: boolean, error?: string, optionId?: string }}
159
+ */
160
+ decide(id, decision, by = 'api') {
161
+ if (decision !== 'allow' && decision !== 'deny') {
162
+ return { ok: false, error: `decision must be "allow" or "deny", got ${JSON.stringify(decision)}` }
163
+ }
164
+ if (!this.pending.has(id)) {
165
+ // Not "silently fine": the page is showing a stale entry, or one that timed out
166
+ // between render and click.
167
+ return { ok: false, error: `no pending approval "${id}" (it may have timed out)` }
168
+ }
169
+ const optionId = decision === 'allow' ? 'allow-once' : 'reject-once'
170
+ this.#settle(id, optionId, by === 'page' ? 'page' : 'api')
171
+ return { ok: true, optionId }
172
+ }
173
+
174
+ /** Settle one entry exactly once, resolve its promise, and audit the outcome. */
175
+ #settle(id, optionId, by) {
176
+ const entry = this.pending.get(id)
177
+ if (entry === undefined || entry.settled) return
178
+ entry.settled = true
179
+ this.pending.delete(id)
180
+ clearTimeout(entry.timer)
181
+
182
+ const settledAt = this.now()
183
+ const outcome = {
184
+ id: entry.id,
185
+ peerId: entry.peerId,
186
+ taskId: entry.taskId,
187
+ toolCallId: entry.toolCallId,
188
+ toolName: entry.toolName,
189
+ requiredTier: entry.requiredTier,
190
+ requestedTier: entry.requestedTier,
191
+ source: entry.source,
192
+ reason: entry.reason,
193
+ optionId,
194
+ by,
195
+ createdAt: entry.createdAt,
196
+ settledAt,
197
+ waitedMs: Math.max(0, settledAt - entry.createdAt),
198
+ }
199
+ this.history.push(outcome)
200
+ if (this.history.length > HISTORY_LIMIT) this.history.splice(0, this.history.length - HISTORY_LIMIT)
201
+
202
+ // The audit hook must never break the approval path it is observing.
203
+ try {
204
+ this.onSettle(outcome)
205
+ } catch { /* audited elsewhere; a logging failure must not change the decision */ }
206
+
207
+ entry.resolve(optionId)
208
+ }
209
+
210
+ /** Refuse everything still waiting. Idempotent. */
211
+ dispose() {
212
+ if (this.disposed) return
213
+ this.disposed = true
214
+ for (const id of [...this.pending.keys()]) this.#settle(id, 'reject-once', 'disposed')
215
+ }
216
+ }
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Append-only audit trail for approvals.
3
+ *
4
+ * WHY IT EXISTS: the page can show what is pending, but "who approved what, and when" has to
5
+ * outlive the process. Without it an approval is indistinguishable from a rule the engine
6
+ * applied silently, which is exactly the question an operator asks after the fact.
7
+ *
8
+ * WHY ITS OWN FILE RATHER THAN THE SETTINGS FILE: the settings file is rewritten whole on
9
+ * every save. An audit trail must be APPEND-ONLY -- a rewrite is a chance to lose the very
10
+ * record you are trying to keep.
11
+ *
12
+ * WHY IT NEVER THROWS: it observes the approval path. A full disk or a locked file must not
13
+ * turn into a failed approval, so every failure is reported through `lastError` and the
14
+ * caller carries on. That is a deliberate trade: an audit trail that can veto an operation
15
+ * is worse than one that reports it could not write.
16
+ */
17
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync } from 'node:fs'
18
+ import { dirname, join } from 'node:path'
19
+
20
+ import { resolveDshHome } from './settings-store.js'
21
+
22
+ /** How many bytes of the tail the page reads. Enough for a screenful, cheap to read. */
23
+ const TAIL_BYTES = 256 * 1024
24
+
25
+ /** Rows kept in memory for the page, newest last. */
26
+ const MEMORY_LIMIT = 200
27
+
28
+ /**
29
+ * Rotate the live file once it reaches this size.
30
+ *
31
+ * WHY A CAP AT ALL: the trail is append-only and, until 0.2.12, unbounded -- it grew for as long as
32
+ * the plugin was installed. An append-only file has no natural stopping point, so "nobody noticed"
33
+ * is exactly what unbounded growth looks like. At the measured ~670 bytes per row this is roughly
34
+ * 12,000 rows, i.e. years of ordinary use: a cap that bounds the worst case without costing an
35
+ * operator anything they would miss.
36
+ */
37
+ const DEFAULT_MAX_BYTES = 8 * 1024 * 1024
38
+
39
+ /** Rotated files kept beside the live one. The oldest beyond this is deleted -- see `#prune`. */
40
+ const DEFAULT_KEEP_FILES = 5
41
+
42
+ /** `audit.<stamp>.jsonl`, i.e. an archive rather than the live `audit.jsonl`. */
43
+ const ARCHIVE_RE = /^audit\..+\.jsonl$/
44
+
45
+ /**
46
+ * The `by` values that mean "nobody decided this".
47
+ *
48
+ * `timeout` is the queue's own budget expiring; `disposed` is the plugin unloading with the
49
+ * request still open; `no-answerer` is the engine finding no one who could answer. All three
50
+ * are refusals the operator had no chance to see at the moment they happened.
51
+ */
52
+ const MISSED_BY = new Set(['timeout', 'disposed', 'no-answerer'])
53
+
54
+ /**
55
+ * The row that moves the operator-facing view's starting point.
56
+ *
57
+ * WHY A WATERMARK AND NOT A DELETE: this file is the evidence trail for "who approved what".
58
+ * Erasing the misses would make the trail lie about what happened, and the operator's own request
59
+ * to clear a list is not a reason to destroy a record of an unattended refusal. Appending a row
60
+ * keeps both facts -- every miss, and the fact that the view was cleared, when, and by which route.
61
+ */
62
+ const CLEARED_KIND = 'approvals-cleared'
63
+
64
+ /** @param {Record<string, string | undefined>} [env] */
65
+ export function auditPath(env = process.env) {
66
+ return join(resolveDshHome(env), 'storages', 'dsh-plugin-remote-use', 'audit.jsonl')
67
+ }
68
+
69
+ export class AuditLog {
70
+ /**
71
+ * @param {object} [opts]
72
+ * @param {Record<string, string | undefined>} [opts.env]
73
+ * @param {() => number} [opts.now]
74
+ * @param {(level: 'warn', message: string) => void} [opts.log]
75
+ */
76
+ constructor(opts = {}) {
77
+ const env = opts.env ?? process.env
78
+ this.file = auditPath(env)
79
+ this.now = opts.now ?? (() => Date.now())
80
+ this.log = opts.log ?? (() => {})
81
+ /** Rotate the live file at this size. <= 0 disables rotation (keep everything). */
82
+ this.maxBytes = Number.isFinite(opts.maxBytes) ? Number(opts.maxBytes) : DEFAULT_MAX_BYTES
83
+ /** How many archives to keep. < 0 keeps every archive. */
84
+ this.keepFiles = Number.isFinite(opts.keepFiles) ? Number(opts.keepFiles) : DEFAULT_KEEP_FILES
85
+ /** @type {object[]} newest last */
86
+ this.rows = []
87
+ this.lastError = ''
88
+ /**
89
+ * WHERE THE VISIBLE VIEW STARTS: the index of the newest clear watermark in `rows`, or -1 when
90
+ * no clear has happened (everything visible).
91
+ *
92
+ * BY POSITION, NOT BY TIMESTAMP. The first implementation compared ISO timestamps, and the test
93
+ * for "a miss recorded after the clear is shown again" failed: `at` has millisecond resolution,
94
+ * so a row written in the same millisecond as the watermark compared as `<=` and vanished. Index
95
+ * order is the file's real order, exact at any speed.
96
+ */
97
+ this.clearedIndex = -1
98
+ /** The watermark's timestamp, for display only. */
99
+ this.clearedAt = ''
100
+ /** Who cleared it, so a rotation can carry the watermark forward without inventing an actor. */
101
+ this.clearedBy = ''
102
+ this.#loadTail()
103
+ }
104
+
105
+ /** Point the view at whatever the newest watermark in memory says (-1 = none). */
106
+ #adoptWatermark() {
107
+ let index = -1
108
+ for (let i = this.rows.length - 1; i >= 0; i -= 1) {
109
+ if (this.rows[i]?.kind === CLEARED_KIND) { index = i; break }
110
+ }
111
+ this.clearedIndex = index
112
+ this.clearedAt = index === -1 || typeof this.rows[index]?.at !== 'string' ? '' : this.rows[index].at
113
+ this.clearedBy = index === -1 ? '' : String(this.rows[index]?.by ?? '')
114
+ }
115
+
116
+ /** Read recent rows so the page has history across a restart. */
117
+ #loadTail() {
118
+ if (!existsSync(this.file)) return
119
+ try {
120
+ const size = statSync(this.file).size
121
+ const start = Math.max(0, size - TAIL_BYTES)
122
+ // A partial first line is expected when reading a tail; JSON.parse drops it below.
123
+ const text = readFileSync(this.file, 'utf8').slice(start)
124
+ for (const line of text.split('\n')) {
125
+ if (line.trim() === '') continue
126
+ try {
127
+ const row = JSON.parse(line)
128
+ if (row !== null && typeof row === 'object') this.rows.push(row)
129
+ } catch { /* a torn or partial line is skipped, never fatal */ }
130
+ }
131
+ if (this.rows.length > MEMORY_LIMIT) this.rows.splice(0, this.rows.length - MEMORY_LIMIT)
132
+ this.#adoptWatermark()
133
+ } catch (error) {
134
+ this.lastError = `could not read ${this.file}: ${error instanceof Error ? error.message : String(error)}`
135
+ this.log('warn', this.lastError)
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Append one row. Never throws.
141
+ * @param {object} row
142
+ * @returns {boolean} whether it reached the disk
143
+ */
144
+ record(row) {
145
+ // Before writing, not after: the point of the cap is that the file never exceeds it.
146
+ this.#rotateIfNeeded()
147
+ const entry = { at: new Date(this.now()).toISOString(), ...row }
148
+ this.rows.push(entry)
149
+ if (this.rows.length > MEMORY_LIMIT) {
150
+ const dropped = this.rows.length - MEMORY_LIMIT
151
+ this.rows.splice(0, dropped)
152
+ // Keep the watermark pointing at its OWN row after a trim. Dropping the watermark's row means
153
+ // every row before it went too, so everything left is after the clear: -1 (all visible) is
154
+ // then the correct answer, not a stale index into a shifted array.
155
+ this.clearedIndex = Math.max(-1, this.clearedIndex - dropped)
156
+ }
157
+ try {
158
+ mkdirSync(dirname(this.file), { recursive: true })
159
+ appendFileSync(this.file, `${JSON.stringify(entry)}\n`, 'utf8')
160
+ this.lastError = ''
161
+ return true
162
+ } catch (error) {
163
+ this.lastError = `could not append to ${this.file}: ${error instanceof Error ? error.message : String(error)}`
164
+ this.log('warn', this.lastError)
165
+ return false
166
+ }
167
+ }
168
+
169
+ /** Recent rows, newest first. */
170
+ tail(limit = 30) {
171
+ return this.rows.slice(-Math.max(0, limit)).reverse()
172
+ }
173
+
174
+ /** Bytes on disk for the live file, 0 when it does not exist yet. */
175
+ get bytes() {
176
+ try {
177
+ return statSync(this.file).size
178
+ } catch {
179
+ return 0
180
+ }
181
+ }
182
+
183
+ /** The rotated archives beside the live file, oldest first. */
184
+ archives() {
185
+ try {
186
+ return readdirSync(dirname(this.file))
187
+ .filter((name) => ARCHIVE_RE.test(name))
188
+ .map((name) => {
189
+ let size = 0
190
+ try { size = statSync(join(dirname(this.file), name)).size } catch { /* raced away */ }
191
+ return { name, bytes: size }
192
+ })
193
+ .sort((a, b) => a.name.localeCompare(b.name))
194
+ } catch {
195
+ return []
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Rotate before the live file would exceed the cap.
201
+ *
202
+ * ROTATE, NEVER TRUNCATE. Truncating the live file is the one operation this class must not do:
203
+ * the whole reason the trail is its own append-only file is that a rewrite is a chance to lose the
204
+ * record you are trying to keep (see the header). Renaming keeps every byte written so far intact
205
+ * and gives the next write a fresh file to append to.
206
+ */
207
+ #rotateIfNeeded() {
208
+ if (!(this.maxBytes > 0)) return
209
+ let size = 0
210
+ try { size = statSync(this.file).size } catch { return } // no file yet: nothing to rotate
211
+ if (size < this.maxBytes) return
212
+ this.#rotate()
213
+ }
214
+
215
+ #rotate() {
216
+ const dir = dirname(this.file)
217
+ // Sortable and second-resolution; a second rotation inside the same second takes a suffix
218
+ // rather than overwriting an archive it just created.
219
+ const stamp = new Date(this.now()).toISOString().replace(/\.\d+Z$/, '').replace(/[-:]/g, '').replace('T', '-')
220
+ let target = join(dir, `audit.${stamp}.jsonl`)
221
+ for (let n = 1; existsSync(target); n += 1) target = join(dir, `audit.${stamp}.${n}.jsonl`)
222
+ try {
223
+ renameSync(this.file, target)
224
+ } catch (error) {
225
+ // Unrotated is the safe failure: the trail keeps growing instead of losing rows.
226
+ this.lastError = `could not rotate ${this.file}: ${error instanceof Error ? error.message : String(error)}`
227
+ this.log('warn', this.lastError)
228
+ return
229
+ }
230
+
231
+ // CARRY THE WATERMARK INTO THE NEW FILE. The clear watermark lives as a ROW, and rotation moves
232
+ // the row it lives in to the archive -- which `#loadTail` does not read. Without this, every
233
+ // restart after a rotation would resurrect the misses the operator had cleared.
234
+ if (this.clearedIndex >= 0) {
235
+ const carried = { at: this.clearedAt, kind: CLEARED_KIND, by: this.clearedBy, carried: true }
236
+ try {
237
+ appendFileSync(this.file, `${JSON.stringify(carried)}\n`, 'utf8')
238
+ } catch (error) {
239
+ this.lastError = `could not carry the clear watermark into ${this.file}: ${error instanceof Error ? error.message : String(error)}`
240
+ this.log('warn', this.lastError)
241
+ }
242
+ }
243
+ this.#prune()
244
+ }
245
+
246
+ /** Delete the oldest archives beyond `keepFiles`. THE ONLY DESTRUCTIVE ACT IN THIS FILE. */
247
+ #prune() {
248
+ if (this.keepFiles < 0) return
249
+ const archives = this.archives()
250
+ const excess = archives.length - this.keepFiles
251
+ if (excess <= 0) return
252
+ for (const archive of archives.slice(0, excess)) {
253
+ try {
254
+ unlinkSync(join(dirname(this.file), archive.name))
255
+ // Reported rather than silent: removing evidence is worth a line in the operator's log,
256
+ // even when it is the retention rule doing exactly what it was configured to do.
257
+ this.log('warn', `audit retention removed the oldest archive: ${archive.name}`)
258
+ } catch (error) {
259
+ this.lastError = `could not remove ${archive.name}: ${error instanceof Error ? error.message : String(error)}`
260
+ this.log('warn', this.lastError)
261
+ }
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Approvals that expired with nobody answering -- the ones the operator never saw.
267
+ *
268
+ * WHY THIS IS NOT JUST `tail()` FILTERED IN THE PAGE: six such rows sat in the live trail on
269
+ * 2026-10-14 (five of them inside eight minutes) and the operator had never seen a single
270
+ * prompt. A refusal that leaves no trace the page can surface is indistinguishable from the
271
+ * plugin being broken, so the misses get their own accessor instead of hiding among the rows
272
+ * that were decided.
273
+ *
274
+ * `disposed` counts as missed: the request was still waiting when the plugin unloaded, so no
275
+ * person ever answered it either. The page labels the two differently.
276
+ *
277
+ * @param {number} [limit] how many to return, newest first
278
+ */
279
+ missed(limit = 20) {
280
+ return this.missedAll().slice(0, Math.max(0, limit))
281
+ }
282
+
283
+ /** Every miss still in the operator-facing view, newest first. */
284
+ missedAll() {
285
+ return this.#visible()
286
+ .filter((row) => row?.kind === 'approval' && MISSED_BY.has(String(row.by ?? '')))
287
+ .reverse()
288
+ }
289
+
290
+ get missedCount() {
291
+ return this.#visible().reduce((n, row) => (row?.kind === 'approval' && MISSED_BY.has(String(row.by ?? '')) ? n + 1 : n), 0)
292
+ }
293
+
294
+ /**
295
+ * Rows at or after the last clear watermark -- what the operator-facing views show.
296
+ */
297
+ #visible() {
298
+ return this.rows.slice(this.clearedIndex + 1)
299
+ }
300
+
301
+ /**
302
+ * Move the view's starting point past everything recorded so far.
303
+ *
304
+ * NOTHING IS DELETED -- see `CLEARED_KIND`. The caller gets the number of misses the views will
305
+ * stop showing, so the page can say what it did instead of looking like it did nothing.
306
+ *
307
+ * @param {string} [by] provenance of the click ('page' for a person, 'api' otherwise)
308
+ * @returns {number} misses the views stop showing
309
+ */
310
+ clear(by = 'api') {
311
+ const cleared = this.missedCount
312
+ this.record({ kind: CLEARED_KIND, by })
313
+ this.clearedIndex = this.rows.length - 1
314
+ this.clearedAt = typeof this.rows[this.clearedIndex]?.at === 'string' ? this.rows[this.clearedIndex].at : ''
315
+ this.clearedBy = String(by)
316
+ return cleared
317
+ }
318
+
319
+ get size() {
320
+ return this.rows.length
321
+ }
322
+ }