hffmnnj.cache-protect 0.0.0-stage → 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/README.md CHANGED
@@ -1,3 +1,147 @@
1
- # Temporary Holding Version
1
+ <p align="center">
2
+ <img src="https://unpkg.com/hffmnnj.cache-protect@0.1.0/assets/banner.png" alt="Cache Protect: a dark vintage kitchen with a lit gas burner, kettle, and brass clock, captioned 'Keep the warm window safe.'" width="100%">
3
+ </p>
2
4
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
5
+ # opencode-cache-protect
6
+
7
+ An OpenCode V2 execution guard that keeps a stale subagent wake from unnecessarily forcing a provider to rebuild a warm prompt cache. It blocks native `subagent` resumes, new `session.prompt` admissions, and context and compaction model dispatch when a non-OpenAI child's last verifiable execution activity is **more than 295 seconds** old. Exactly 295 seconds remains allowed. Official `openai` sessions and identified primary sessions are exempt.
8
+
9
+ A rejected native tool call tells the orchestrator to spawn a fresh worker, omit `sessionID`, provide a concise handoff (goal, relevant findings, remaining work), and avoid interrupting or duplicating work that is still running. The plugin never spawns the replacement itself, interrupts a worker, deletes history, sends keepalives, or makes provider requests.
10
+
11
+ Built against OpenCode V2's 2.0.25 plugin API. The optional local `prompt-cache/` entrypoint provides cross-provider context projection without replacing or changing the safety plugin.
12
+
13
+ ## Install
14
+
15
+ The guard is published as `hffmnnj.cache-protect@0.1.0`; its catalog ID is `hffmnnj.cache-protect`.
16
+
17
+ Add the package to the `plugins` list in your OpenCode V2 configuration, preserving existing entries:
18
+
19
+ ```json
20
+ {
21
+ "plugins": [
22
+ { "package": "hffmnnj.cache-protect" }
23
+ ]
24
+ }
25
+ ```
26
+
27
+ OpenCode loads the package's `./dist/index.js` export. It watches configuration, so the plugin reloads without restarting the service.
28
+
29
+ If you previously loaded this checkout by directory, replace that entry with the npm package entry. Do not keep both: the old and new entries would register the same safety hooks twice.
30
+
31
+ The guard's former `opencode.cache-protect` runtime ID is not retained. It does not use plugin storage, so no persisted state is tied to the old ID and replacing the old entry is safe.
32
+
33
+ For local development, build the checkout with `bun install --frozen-lockfile` and `bun run build`, then use the checkout's absolute directory as a `package` entry. The directory's `index.ts` loads the built `dist/index.js`.
34
+
35
+ To disable it, remove only this plugin entry from the current configuration. Do not restore an entire old configuration over concurrent changes. If you also added matching orchestrator guidance, update it to match the 295-second cutoff and no-keepalive policy, or remove it separately.
36
+
37
+ ## What it guards
38
+
39
+ - `tool.hook("execute.before")` checks native `subagent` calls that include an existing `input.sessionID`. A fresh spawn without that field passes through unchanged.
40
+ - `session.hook("prompt")` checks direct messages to child sessions, including prompts issued internally by the native executor. A fresh child's initial prompt is allowed.
41
+ - `session.hook("context")` checks model-request context preparation. Native retries of already-admitted prompt IDs skip the prompt hook but still wake execution; this check prevents a stale pending prompt from reaching the provider.
42
+ - `session.hook("compaction")` checks compaction requests, which can run before primary context. Neither dispatch hook counts its own attempt as activity.
43
+ - Successfully identified primary sessions (no `parentID`) are not age-restricted. Unreadable or malformed target metadata cannot be classified as primary and fails closed.
44
+ - Official `openai` provider sessions are exempt from the stale-child cutoff. This exemption does not disable the single-main-server safeguard or broaden diagnostic access.
45
+ - Every check shares one **2,000 ms lookup deadline** across its metadata and context reads, with cancellation. Missing, malformed, future, or unreadable required timestamps/history block admission or dispatch rather than silently admitting a potentially stale resume.
46
+ - The safety hooks do not modify inputs, replace the executor/schema, or alter permissions. Other native and plugin hooks remain in control.
47
+
48
+ ## Activity evidence
49
+
50
+ Activity is derived on demand from native persisted state, with no private activity store or live-only subscription:
51
+
52
+ | Native evidence | Use |
53
+ | --- | --- |
54
+ | `Session.Info.time.created` | Initial baseline, including a child's first prompt |
55
+ | Assistant `time.created` | Native model step dispatch timestamp projected from `session.step.started` |
56
+ | Assistant `time.streamed` / `time.completed` | Provider-body completion / step settlement |
57
+ | Assistant tool `time.ran` / `time.completed` | Tool call / settlement during a running step; aborted settlement completion is excluded |
58
+ | Completed compaction `time.created`, only when `tokens.output` is positive | Model-backed auto-compaction (a real provider call) |
59
+
60
+ The latest valid checkpoint wins. `time.idle` is **never** activity, regardless of outcome: both a denied execution and a successful delivered-ID empty wake can advance it without a model call. Native wake/reopen can also mark abandoned tools aborted without executing them again, so those completion timestamps are ignored. Recent real model/tool work remains eligible even with an old terminal idle timestamp. `time.updated`, `viewed`, titles, reads, user/synthetic messages, compaction controls/summaries without model output, tool-input construction timestamps, and the plugin's own setup/attempt times do not refresh activity. Reloading the plugin or reopening the host reconstructs the decision from native checkpoints, not a new initialization time.
61
+
62
+ ## Status snapshot
63
+
64
+ Discover `cache_protect_status` in Code Mode, then call its returned path with `{}` for the caller or `{ "sessionID": "ses_..." }` for a verified descendant. Siblings, ancestors, unrelated targets, malformed metadata, and lookup failures return a sanitized `unverifiable` result without target details.
65
+
66
+ The tool returns `status` (`allowed`, `stale`, `exempt_openai`, `exempt_primary`, or `unverifiable`), `policy` (the fixed `durable_meaningful_activity` label), and `cutoffMs`; applicable decisions include `activityAgeMs`. Stale and unverifiable results include a concise fresh-worker handoff. It shares enforcement's calculation and 2,000 ms lookup deadline, and descendant checks are bounded to 32 ancestry levels. It never refreshes activity, messages a session, or makes provider requests. A status is a snapshot, not an admission reservation or override; inspecting or externally running background work never extends freshness.
67
+
68
+ ## Single-main-server safeguard
69
+
70
+ The default entrypoint verifies that the plugin process owns the managed OpenCode server before any tool execution and before the `prompt`, `context`, `compaction`, `generate`, and `title` session hooks.
71
+
72
+ On Linux, it compares `process.pid` with the `MainPID` of the fixed `opencode-web-v2.service` user unit through `/usr/bin/systemctl` (5 s timeout, 128-byte output cap).
73
+
74
+ On Windows, it reads the managed service registration from `$XDG_STATE_HOME/opencode/service.json` (or `~/.local/state/opencode/service.json`). It accepts only loopback HTTP registrations for `127.0.0.1` or `[::1]` with no URL credentials, path, query, or fragment. When the registration carries a password, it authenticates the `/api/info` probe with HTTP Basic authentication and rejects redirects; the advertised PID and version must match the registration, and the registered PID must equal the plugin process. The registration probe has a one-second deadline and fails closed on missing, invalid, unreachable, or mismatched registrations. Registration credentials are never logged. Other platforms retain the fixed systemd-user-unit check. Standalone hosts, non-loopback registrations, and otherwise unregistered hosts remain blocked by design.
75
+
76
+ Both lookups share one in-flight call and cache a successful match for at most five seconds. A failed lookup within five minutes of a successful verification remains allowed, because a busy host can stall the lookup; a definite PID mismatch blocks immediately and clears that grace. Lookup failure outside the grace period or a mismatch denies with a sanitized `Tool.Error`.
77
+
78
+ Its shell pre-check rejects commands that would launch an OpenCode CLI (`opencode`, `opencodev2`, or `opencode2`, including `.exe`) directly or through common wrappers, including an unresolved `$CLI`; ordinary file and path arguments remain allowed. On Windows, a line-oriented scan additionally recognizes direct PowerShell invocation forms, including the Desktop `opencode-cli.exe` and `.cmd` or `.bat` launchers; it does not interpret arbitrary scripts or dynamically generated commands. Unsupported or ambiguous forms fail closed.
79
+
80
+ Heredoc boundary: quoted bodies (for example `python3 - <<'PY'`, `node <<'JS'`, `cat <<'EOF'`) are data and stay allowed, including any `$(...)` or backticks inside them. In unquoted bodies, only `$(...)` and backtick expansions are checked. Heredocs that feed a shell (`bash <<'EOF'`, `cat <<'EOF' | bash`) are checked as shell code, while a non-shell consumer under `bash -c` (for example `bash -c "cat"`) with a quoted body stays allowed. An actual OpenCode command before or after a heredoc still blocks.
81
+
82
+ It is an accidental-execution safeguard, not an interpreter or hostile-code sandbox, and not isolation from other processes running as the same user. Encoded or interpreted programs and other process-launch paths remain outside the classifier's boundary.
83
+
84
+ ## Limits
85
+
86
+ - **Execution boundary.** This is an execution guard at native tool, prompt-admission, and context and compaction request hooks, not an isolation or security boundary against arbitrary plugins, direct storage writes, shell/HTTP injection, or auxiliary APIs such as transient generation that do not invoke those hooks.
87
+ - **Admitted retries.** An already-admitted prompt ID retry can still return its original admission and cause native wake, inbox promotion, and terminal/idle transitions. The dispatch guard prevents a stale child provider request; it does not undo previously admitted history or turn the retry API into a typed rejection. Repeated retries, including successful empty wakes, do not refresh freshness.
88
+ - **Checkpoint granularity.** It measures native durable execution checkpoints, not every streaming token. Before the native host publishes a model step (which can be deferred until the first provider output), only earlier persisted activity is available. A long request with no newer durable checkpoint can have additional sends conservatively rejected. The running request itself is never interrupted. Do not automatically duplicate ongoing work.
89
+ - **Compaction visibility.** Native context omits history before the latest completed compaction. A completed compaction counts at its creation time only when `tokens.output` is positive; a hook-supplied or control summary without output tokens never counts. Creation time can only under-state recency. A plugin that supplies output tokens can still count, because plugins are trusted in-process code and the guard targets accidental stale resumes. If no checkpoint is visible, the guard falls back to creation time.
90
+ - **Rejection surfaces.** Rejection text is model-visible in the native tool result. In V2 2.0.25, throwing from the direct prompt admission hook is mapped by the HTTP adapter to a generic `ClientError: UnexpectedStatus: 500`; the actionable `Tool.Error` text is not preserved by that adapter. A rejected new admission fails before inbox insertion or a model request. Dispatch-hook failures instead settle the awakened execution as failed, before primary step creation/provider dispatch.
91
+ - **No cache guarantee.** This does not guarantee any provider's prompt-cache retention, billing behavior, or cache hit rate.
92
+ - **Plugin composition.** Composition with other plugins uses native hook registration, not executor replacement. Compatibility was reviewed statically for a workstreams-style wrapper; combined delegation against a paid provider was not exercised.
93
+
94
+ ## Prompt-cache projection
95
+
96
+ The separate, optional local `prompt-cache/` entrypoint registers the stateful runtime ID `opencode.prompt-cache`. It does not move, replace, or alter the safety plugin. Build it from a source checkout with `bun run build:prompt-cache` before loading it.
97
+
98
+ Load it as its own plugin entry after all context-producing plugins, including Goals, Memory, Clef, and any other plugin that injects transient context:
99
+
100
+ ```json
101
+ {
102
+ "plugins": [
103
+ { "package": "/path/to/opencode-cache-protect/prompt-cache" }
104
+ ]
105
+ }
106
+ ```
107
+
108
+ Keep exactly one prompt-cache entry; two copies would both project the same snapshots and duplicate the policy part. Its projection uses the 2.0.32 message-constructor submodule, bundled into its output. Do not externalize the entire AI package here: loading its provider graph can pull incompatible runtime internals into the plugin sandbox. Existing safety dependencies remain pinned.
109
+
110
+ On primary requests for every model provider, it keeps earlier Goals, Memory, and Clef context snapshots in their original positions and appends changed snapshots. Unchanged payloads are not repeated; the latest revision for each source supersedes older ones. All current payloads and refresh behavior remain available. Retention is intentional and can grow context faster, which may cause earlier compaction. Eligible primary requests always carry exactly one copy of the instruction that snapshots are quoted task data, not instructions, even before the first snapshot appears or after all sources are suppressed; a failed storage read leaves the request untouched.
111
+
112
+ Native plugin storage retains injected snapshot text, canonical-history hashes, and the request's system hash and model identity. It does not retain a duplicate of the authored conversation or tools. Compaction and genuine model/system/history changes reset the projection; inactive sources are removed to preserve suppression and disable behavior. Auxiliary requests are unchanged. OpenAI storage keys remain compatible; every other provider's state is provider-qualified so identical model IDs cannot share snapshots. No wire hooks, explicit cache fields, warming, retries, TTL overrides, or cache keys are introduced.
113
+
114
+ Projection can be restricted with `options.sessions`, an array of exact session IDs:
115
+
116
+ ```json
117
+ {
118
+ "package": "/path/to/opencode-cache-protect/prompt-cache",
119
+ "options": { "sessions": ["ses_..."] }
120
+ }
121
+ ```
122
+
123
+ Omitting this option enables all eligible sessions; an invalid option disables projection. Diagnostic logs contain session/model identity, snapshot counts, reset state, or a fixed storage-failure category, never prompt text or credentials.
124
+
125
+ Intentional instruction or catalog changes, compaction, account switches, and provider expiry can still invalidate caches; the projection does not promise cache hits. Actual reuse must be established with ordinary real-work usage; local continuation tests are not evidence of server cache reuse. Explicit checkpoints are deliberately not used. Goals usage counters belong in the full Goals tool response, not the injected request tail; otherwise every tool step creates a new snapshot without changing task state. The cross-provider audit and acceptance scope are in [docs/SPEC.md](https://github.com/hffmnnj/opencode-cache-protect/blob/main/docs/SPEC.md).
126
+
127
+ Validate the watched artifact in the actual V2 plugin catalog and native requests; a plain Bun import is not an equivalent plugin-loading test.
128
+
129
+ ## Development
130
+
131
+ Dependencies are pinned. Run these from the checkout:
132
+
133
+ ```sh
134
+ bun install --frozen-lockfile
135
+ bun run typecheck
136
+ bun test tests/unit
137
+ bun run build
138
+ bun run check # typecheck, deterministic unit tests, both local builds
139
+ bun run test:host # real isolated SDK 2.0.25 native host
140
+ bun test # full plugin suite, after build
141
+ ```
142
+
143
+ `bun run check` builds the safety entrypoint (`dist/index.js`) and the local prompt-cache watch entrypoint (`dist/prompt-cache.js` with generated `prompt-cache/index.ts`).
144
+
145
+ The host test launches a child process with isolated HOME/XDG/config/database paths under `/var/tmp/james/opencode/cache-protect-host-*`. Its only configured provider is a loopback scripted server with a synthetic key. It disables built-in providers and model fetching/snapshots; it does not borrow credentials or make paid calls. Scripted provider requests exercise the real native schema, executor, prompt/context hooks, history, inbox, permission filtering, and package loader. Regressions include stale pending-ID wake, repeated denied/empty wakes, host reopen, and a delivered-ID no-op wake followed by a rejected new stale prompt. Hosts and loopback servers are closed in `finally`; isolated evidence directories are retained rather than recursively deleted.
146
+
147
+ `registerCacheProtect` exposes an internal injected clock for deterministic tests. This is not a user configuration override; the shipped default always uses `Date.now()` and the fixed cutoff.
Binary file
package/dist/index.js ADDED
@@ -0,0 +1,708 @@
1
+ // @bun
2
+ // src/index.ts
3
+ import { Plugin } from "@opencode/plugin";
4
+ import * as Tool2 from "@opencode/plugin/promise/tool";
5
+
6
+ // src/server-safety.ts
7
+ import { execFile } from "child_process";
8
+ import * as Tool from "@opencode/plugin/promise/tool";
9
+
10
+ // src/windows-service-owner.ts
11
+ import { open } from "fs/promises";
12
+ import { homedir } from "os";
13
+ import { join } from "path";
14
+ var MAX_REGISTRATION_BYTES = 16384;
15
+ async function readRegistration(signal) {
16
+ const state = process.env.XDG_STATE_HOME || join(homedir(), ".local", "state");
17
+ const file = await open(join(state, "opencode", "service.json"), "r");
18
+ try {
19
+ signal.throwIfAborted();
20
+ const info = await file.stat();
21
+ if (!info.isFile() || info.size < 1 || info.size > MAX_REGISTRATION_BYTES)
22
+ throw new Error;
23
+ const buffer = Buffer.alloc(MAX_REGISTRATION_BYTES + 1);
24
+ const result = await file.read(buffer, 0, buffer.length, 0);
25
+ signal.throwIfAborted();
26
+ if (result.bytesRead < 1 || result.bytesRead > MAX_REGISTRATION_BYTES)
27
+ throw new Error;
28
+ return buffer.subarray(0, result.bytesRead).toString("utf8");
29
+ } finally {
30
+ await file.close();
31
+ }
32
+ }
33
+ function record(value) {
34
+ if (!value || typeof value !== "object" || Array.isArray(value))
35
+ throw new Error;
36
+ return value;
37
+ }
38
+ async function windowsServiceMainPID(options = {}) {
39
+ const controller = new AbortController;
40
+ let timer;
41
+ try {
42
+ const deadline = new Promise((_resolve, reject) => {
43
+ timer = setTimeout(() => {
44
+ controller.abort();
45
+ reject(new Error);
46
+ }, 1000);
47
+ });
48
+ const operation = async () => {
49
+ const registration = record(JSON.parse(await (options.read ?? readRegistration)(controller.signal)));
50
+ if (typeof registration.url !== "string" || !Number.isSafeInteger(registration.pid) || registration.pid <= 0 || typeof registration.version !== "string" || !registration.version || registration.password !== undefined && (typeof registration.password !== "string" || !registration.password))
51
+ throw new Error;
52
+ const url = new URL(registration.url);
53
+ if (url.protocol !== "http:" || !["127.0.0.1", "[::1]"].includes(url.hostname) || url.username || url.password || url.pathname !== "/" || url.search || url.hash)
54
+ throw new Error;
55
+ const headers = {};
56
+ if (typeof registration.password === "string") {
57
+ headers.authorization = `Basic ${Buffer.from(`opencode:${registration.password}`).toString("base64")}`;
58
+ }
59
+ const response = await (options.fetch ?? fetch)(new URL("/api/info", url).href, {
60
+ method: "GET",
61
+ headers,
62
+ redirect: "error",
63
+ signal: controller.signal
64
+ });
65
+ if (!response.ok)
66
+ throw new Error;
67
+ const info = record(await response.json());
68
+ if (info.pid !== registration.pid || info.version !== registration.version)
69
+ throw new Error;
70
+ return registration.pid;
71
+ };
72
+ return await Promise.race([operation(), deadline]);
73
+ } catch {
74
+ throw new Error("Main identity unavailable");
75
+ } finally {
76
+ if (timer)
77
+ clearTimeout(timer);
78
+ controller.abort();
79
+ }
80
+ }
81
+
82
+ // src/server-safety.ts
83
+ function documentExpansions(body) {
84
+ const scripts = [];
85
+ function substitution(start, backtick) {
86
+ let quote = "";
87
+ let parentheses = 0;
88
+ for (let i = start;i < body.length; i++) {
89
+ const char = body[i];
90
+ if (char === "\\" && quote !== "'") {
91
+ if (backtick && /[$`\\]/.test(body[i + 1] ?? ""))
92
+ return;
93
+ i++;
94
+ continue;
95
+ }
96
+ if (quote === "'") {
97
+ if (char === "'")
98
+ quote = "";
99
+ continue;
100
+ }
101
+ if (backtick && char === "`") {
102
+ scripts.push(body.slice(start, i));
103
+ return i;
104
+ }
105
+ if (char === "`" || char === "$" && body[i + 1] === "(") {
106
+ return;
107
+ }
108
+ if (quote) {
109
+ if (char === quote)
110
+ quote = "";
111
+ continue;
112
+ }
113
+ if (char === "'" || char === '"') {
114
+ quote = char;
115
+ continue;
116
+ }
117
+ if (char === "#" || char === "<" && body[i + 1] === "<")
118
+ return;
119
+ if (!backtick && char === "(")
120
+ parentheses++;
121
+ if (!backtick && char === ")") {
122
+ if (parentheses-- === 0) {
123
+ scripts.push(body.slice(start, i));
124
+ return i;
125
+ }
126
+ }
127
+ }
128
+ return;
129
+ }
130
+ for (let i = 0;i < body.length; i++) {
131
+ if (body[i] === "\\" && /[$`\\\n]/.test(body[i + 1] ?? "")) {
132
+ i++;
133
+ continue;
134
+ }
135
+ if (body[i] !== "`" && !(body[i] === "$" && body[i + 1] === "("))
136
+ continue;
137
+ const end = substitution(i + (body[i] === "`" ? 1 : 2), body[i] === "`");
138
+ if (end === undefined)
139
+ return;
140
+ i = end;
141
+ }
142
+ return scripts;
143
+ }
144
+ function tokens(command) {
145
+ const result = [];
146
+ const documents = [];
147
+ let value = "";
148
+ let started = false;
149
+ let quote = "";
150
+ let grouping = 0;
151
+ const flush = () => {
152
+ if (started)
153
+ result.push({ value, separator: false });
154
+ value = "";
155
+ started = false;
156
+ };
157
+ for (let i = 0;i < command.length; i++) {
158
+ const char = command[i];
159
+ if (char === "\\" && quote !== "'" && i + 1 < command.length) {
160
+ const next = command[++i];
161
+ if (next !== `
162
+ `) {
163
+ value += next;
164
+ started = true;
165
+ }
166
+ } else if (quote) {
167
+ if (char === quote)
168
+ quote = "";
169
+ else
170
+ value += char;
171
+ } else if (char === "#" && !started) {
172
+ const newline = command.indexOf(`
173
+ `, i);
174
+ i = newline === -1 ? command.length : newline - 1;
175
+ } else if (char === "<" && command[i - 1] !== "<" && command[i + 1] === "<" && command[i + 2] !== "<") {
176
+ if (grouping)
177
+ return;
178
+ if (/^\d+$/.test(value)) {
179
+ value = "";
180
+ started = false;
181
+ }
182
+ flush();
183
+ let cursor = i + 2;
184
+ const stripTabs = command[cursor] === "-";
185
+ if (stripTabs)
186
+ cursor++;
187
+ while (command[cursor] === " " || command[cursor] === "\t")
188
+ cursor++;
189
+ let delimiter = "";
190
+ let delimiterQuote = "";
191
+ let quoted = false;
192
+ let delimiterStarted = false;
193
+ for (;cursor < command.length; cursor++) {
194
+ const next = command[cursor];
195
+ if (next === `
196
+ ` && delimiterQuote)
197
+ return;
198
+ if (!delimiterQuote && /[\s;|&()<>{}]/.test(next))
199
+ break;
200
+ delimiterStarted = true;
201
+ if (next === "\\" && delimiterQuote !== "'") {
202
+ const escaped = command[++cursor];
203
+ if (escaped === undefined || escaped === `
204
+ `)
205
+ return;
206
+ if (delimiterQuote === '"' && !/[$`"\\]/.test(escaped))
207
+ delimiter += "\\";
208
+ delimiter += escaped;
209
+ quoted = true;
210
+ } else if (delimiterQuote) {
211
+ if (next === delimiterQuote)
212
+ delimiterQuote = "";
213
+ else
214
+ delimiter += next;
215
+ } else if (next === "'" || next === '"') {
216
+ delimiterQuote = next;
217
+ quoted = true;
218
+ } else if (next === "$" || next === "`")
219
+ return;
220
+ else
221
+ delimiter += next;
222
+ }
223
+ if (!delimiterStarted || delimiterQuote)
224
+ return;
225
+ const token = { value: "", separator: false, body: "" };
226
+ result.push(token);
227
+ documents.push({ delimiter, quoted, stripTabs, token });
228
+ i = cursor - 1;
229
+ } else if (char === "'" || char === '"') {
230
+ quote = char;
231
+ started = true;
232
+ } else if (`;|&
233
+ (){}`.includes(char)) {
234
+ flush();
235
+ if (documents.length && ("(){}".includes(char) || char === "&" && command[i - 1] === "|" || char === `
236
+ ` && result.at(-1)?.value === "|"))
237
+ return;
238
+ if (char === "(" || char === "{")
239
+ grouping++;
240
+ if (char === ")" || char === "}")
241
+ grouping = Math.max(0, grouping - 1);
242
+ result.push({ value: char, separator: true });
243
+ if (char === `
244
+ ` && documents.length) {
245
+ let cursor = i + 1;
246
+ for (const document of documents) {
247
+ let body = "";
248
+ let closed = false;
249
+ while (cursor < command.length) {
250
+ const newline = command.indexOf(`
251
+ `, cursor);
252
+ const end = newline === -1 ? command.length : newline;
253
+ const line = command.slice(cursor, end);
254
+ const content = document.stripTabs ? line.replace(/^\t+/, "") : line;
255
+ cursor = newline === -1 ? command.length : newline + 1;
256
+ if (content === document.delimiter) {
257
+ closed = true;
258
+ break;
259
+ }
260
+ body += content + (newline === -1 ? "" : `
261
+ `);
262
+ }
263
+ if (!closed)
264
+ return;
265
+ document.token.body = body;
266
+ if (!document.quoted) {
267
+ if (body.includes("\\\n"))
268
+ return;
269
+ const expansions = documentExpansions(body);
270
+ if (!expansions)
271
+ return;
272
+ for (const expansion of expansions)
273
+ result.push({ value: "", separator: false, expansion });
274
+ }
275
+ }
276
+ documents.length = 0;
277
+ i = cursor - 1;
278
+ }
279
+ } else if (/\s/.test(char))
280
+ flush();
281
+ else {
282
+ value += char;
283
+ started = true;
284
+ }
285
+ }
286
+ flush();
287
+ return documents.length ? undefined : result;
288
+ }
289
+ function leaf(value) {
290
+ return value.replaceAll("\\", "/").split("/").at(-1).toLowerCase();
291
+ }
292
+ function isWindowsOpenCodeShellCommand(command) {
293
+ const heads = /(?:^|[;&|\r\n])\s*(?:&\s*)?(?:"([^"]+)"|'([^']+)'|([^\s;&|]+))/g;
294
+ for (const match of command.matchAll(heads)) {
295
+ const name = leaf(match[1] ?? match[2] ?? match[3] ?? "");
296
+ if (/^opencode(?:-cli|v2|2)?(?:\.exe|\.cmd|\.bat)?$/.test(name))
297
+ return true;
298
+ }
299
+ return false;
300
+ }
301
+ function isOpenCodeShellCommand(command) {
302
+ if (process.platform === "win32" && !command.includes("<<") && isWindowsOpenCodeShellCommand(command))
303
+ return true;
304
+ return classify(command, new Map, 0);
305
+ }
306
+ function classify(command, variables, depth) {
307
+ if (depth > 8)
308
+ return true;
309
+ const words = tokens(command);
310
+ if (!words)
311
+ return true;
312
+ let executable = true;
313
+ let wrapper = "";
314
+ let skip = false;
315
+ for (let i = 0;i < words.length; i++) {
316
+ const token = words[i];
317
+ if (token.expansion !== undefined) {
318
+ if (classify(token.expansion, new Map(variables), depth + 1))
319
+ return true;
320
+ continue;
321
+ }
322
+ if (token.body !== undefined)
323
+ continue;
324
+ if (token.separator) {
325
+ executable = true;
326
+ wrapper = "";
327
+ skip = false;
328
+ continue;
329
+ }
330
+ if (!executable)
331
+ continue;
332
+ let word = token.value;
333
+ const assignment = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/s.exec(word);
334
+ if (assignment) {
335
+ variables.set(assignment[1], assignment[2]);
336
+ continue;
337
+ }
338
+ word = word.replace(/\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/g, (original, braced, bare) => variables.get(braced ?? bare) ?? original);
339
+ if (skip) {
340
+ skip = false;
341
+ continue;
342
+ }
343
+ if (word.startsWith("-") && wrapper) {
344
+ if (wrapper === "env" && ["-u", "--unset", "-C", "--chdir"].includes(word) || wrapper === "timeout" && ["-s", "--signal", "-k", "--kill-after"].includes(word))
345
+ skip = true;
346
+ continue;
347
+ }
348
+ const name = leaf(word);
349
+ if (/^\$(?:CLI|\{CLI\})$/.test(word))
350
+ return true;
351
+ if (/^opencode(?:v2|2)?(?:\.exe)?$/.test(name))
352
+ return true;
353
+ if (["env", "command", "exec", "nohup", "timeout"].includes(name)) {
354
+ wrapper = name;
355
+ continue;
356
+ }
357
+ if (wrapper === "timeout" && /^\d/.test(word)) {
358
+ wrapper = "";
359
+ continue;
360
+ }
361
+ if (["sh", "bash", "dash", "zsh", "ksh"].includes(name)) {
362
+ let end = i + 1;
363
+ while (end < words.length && !words[end].separator)
364
+ end++;
365
+ const args = words.slice(i + 1, end).filter((arg) => arg.body === undefined);
366
+ let commandString = false;
367
+ let optionPosition = true;
368
+ for (let j = 0;j < args.length; j++) {
369
+ if (!args[j].value.startsWith("-") || ["-", "--"].includes(args[j].value))
370
+ optionPosition = false;
371
+ if (/^-[a-z]*c[a-z]*$/.test(args[j].value)) {
372
+ const script = args[j + 1];
373
+ if (!script || classify(script.value, new Map(variables), depth + 1))
374
+ return true;
375
+ commandString = optionPosition;
376
+ break;
377
+ }
378
+ }
379
+ if (!commandString) {
380
+ let start = i;
381
+ while (start > 0 && (!words[start - 1].separator || words[start - 1].value === "|"))
382
+ start--;
383
+ const bodies = words.slice(start, end).filter((part) => part.body !== undefined);
384
+ if (bodies.length) {
385
+ if (args.some((arg) => !["-", "--"].includes(arg.value) && !/^-[abefhkmnptuvxBCEHPTsil]+$/.test(arg.value)))
386
+ return true;
387
+ for (const document of bodies) {
388
+ if (classify(document.body, new Map(variables), depth + 1))
389
+ return true;
390
+ }
391
+ }
392
+ }
393
+ }
394
+ if (["then", "do", "else", "if", "elif", "!"].includes(word))
395
+ continue;
396
+ executable = false;
397
+ wrapper = "";
398
+ }
399
+ return false;
400
+ }
401
+ function mainPID() {
402
+ if (process.platform === "win32")
403
+ return windowsServiceMainPID();
404
+ return new Promise((resolve, reject) => {
405
+ execFile("/usr/bin/systemctl", ["--user", "show", "opencode-web-v2.service", "--property=MainPID", "--value"], { timeout: LOOKUP_TIMEOUT_MS, maxBuffer: 128, encoding: "utf8" }, (error, stdout) => {
406
+ if (error || !/^\d+\s*$/.test(stdout)) {
407
+ reject(new Error("Main identity unavailable"));
408
+ return;
409
+ }
410
+ resolve(Number(stdout.trim()));
411
+ });
412
+ });
413
+ }
414
+ var LOOKUP_TIMEOUT_MS = 5000;
415
+ var FRESH_MS = 5000;
416
+ var LOOKUP_GRACE_MS = 5 * 60000;
417
+ async function registerServerSafety(ctx, runtime = {}) {
418
+ const pid = runtime.pid ?? process.pid;
419
+ const resolveMainPID = runtime.mainPID ?? mainPID;
420
+ const now = runtime.now ?? (() => performance.now());
421
+ let verifiedAt;
422
+ let checkedAt;
423
+ let inflight;
424
+ const blocked = () => new Tool.Error({ message: "Server safety blocked execution: this process could not be verified as the single main OpenCode server. Use the existing main server's native tools; do not launch another server or CLI session." });
425
+ async function verify() {
426
+ let expected;
427
+ try {
428
+ expected = await resolveMainPID();
429
+ } catch {
430
+ if (verifiedAt !== undefined && now() - verifiedAt < LOOKUP_GRACE_MS) {
431
+ checkedAt = now();
432
+ return;
433
+ }
434
+ verifiedAt = checkedAt = undefined;
435
+ throw blocked();
436
+ }
437
+ if (!Number.isSafeInteger(expected) || expected <= 0 || expected !== pid) {
438
+ verifiedAt = checkedAt = undefined;
439
+ throw blocked();
440
+ }
441
+ verifiedAt = checkedAt = now();
442
+ }
443
+ async function ownerGuard() {
444
+ if (checkedAt !== undefined && now() - checkedAt < FRESH_MS)
445
+ return;
446
+ inflight ??= verify().finally(() => {
447
+ inflight = undefined;
448
+ });
449
+ await inflight;
450
+ }
451
+ const registrations = [];
452
+ const dispose = async () => {
453
+ await Promise.all(registrations.map((registration) => registration.dispose()));
454
+ };
455
+ try {
456
+ registrations.push(await ctx.tool.hook("execute.before", async (event) => {
457
+ await ownerGuard();
458
+ if (event.tool !== "shell")
459
+ return;
460
+ const input = event.input;
461
+ if (input && typeof input === "object" && "command" in input && typeof input.command === "string" && isOpenCodeShellCommand(input.command)) {
462
+ throw new Tool.Error({ message: "Server safety blocked an OpenCode CLI shell command. Use native subagent/tools and the existing main server API; do not launch another OpenCode process." });
463
+ }
464
+ }));
465
+ for (const name of ["prompt", "context", "compaction", "generate", "title"]) {
466
+ registrations.push(await ctx.session.hook(name, ownerGuard));
467
+ }
468
+ return dispose;
469
+ } catch (error) {
470
+ await dispose();
471
+ throw error;
472
+ }
473
+ }
474
+
475
+ // src/index.ts
476
+ var MAX_INACTIVITY_MS = 295000;
477
+ var LOOKUP_DEADLINE_MS = 2000;
478
+ var MAX_ANCESTRY_DEPTH = 32;
479
+ var HANDOFF = "Spawn a fresh subagent: omit sessionID and provide a concise handoff (goal, relevant findings, remaining work). Do not interrupt or duplicate work that is still running.";
480
+ function record2(value) {
481
+ if (!value || typeof value !== "object" || Array.isArray(value))
482
+ throw new Error("Invalid metadata");
483
+ return value;
484
+ }
485
+ function timestamp(value, now) {
486
+ if (!Number.isFinite(now) || now < 0 || typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > now) {
487
+ throw new Error("Invalid activity timestamp");
488
+ }
489
+ return value;
490
+ }
491
+ function modelBackedCompaction(compaction) {
492
+ const output = record2(compaction.tokens ?? {}).output;
493
+ return typeof output === "number" && output > 0;
494
+ }
495
+ function lastActivity(info, context, now) {
496
+ const time = record2(info.time);
497
+ let last = timestamp(time.created, now);
498
+ if (!Array.isArray(context))
499
+ throw new Error("Invalid session context");
500
+ for (const item of context) {
501
+ const message = record2(item);
502
+ if (message.type === "compaction") {
503
+ if (modelBackedCompaction(message))
504
+ last = Math.max(last, timestamp(record2(message.time).created, now));
505
+ continue;
506
+ }
507
+ if (message.type !== "assistant")
508
+ continue;
509
+ const times = record2(message.time);
510
+ last = Math.max(last, timestamp(times.created, now));
511
+ for (const key of ["streamed", "completed"]) {
512
+ if (times[key] !== undefined)
513
+ last = Math.max(last, timestamp(times[key], now));
514
+ }
515
+ if (!Array.isArray(message.content))
516
+ throw new Error("Invalid assistant context");
517
+ for (const part of message.content) {
518
+ const content = record2(part);
519
+ if (content.type !== "tool")
520
+ continue;
521
+ const toolTime = record2(content.time);
522
+ for (const key of ["ran", "completed"]) {
523
+ if (key === "completed" && content.state !== undefined) {
524
+ const state = record2(content.state);
525
+ if (state.status === "error" && record2(state.error).type === "aborted")
526
+ continue;
527
+ }
528
+ if (toolTime[key] !== undefined)
529
+ last = Math.max(last, timestamp(toolTime[key], now));
530
+ }
531
+ }
532
+ }
533
+ return last;
534
+ }
535
+ async function boundedLookup(operation, signal) {
536
+ const controller = new AbortController;
537
+ const deadline = performance.now() + LOOKUP_DEADLINE_MS;
538
+ const checkDeadline = () => {
539
+ controller.signal.throwIfAborted();
540
+ if (performance.now() > deadline)
541
+ throw new Error("Activity lookup deadline");
542
+ };
543
+ let timer;
544
+ let onAbort;
545
+ try {
546
+ if (signal?.aborted)
547
+ throw new Error("Cancelled");
548
+ const result = await Promise.race([
549
+ operation({ signal: controller.signal, check: checkDeadline }),
550
+ new Promise((_, reject) => {
551
+ timer = setTimeout(() => {
552
+ controller.abort();
553
+ reject(new Error("Activity lookup deadline"));
554
+ }, LOOKUP_DEADLINE_MS);
555
+ onAbort = () => {
556
+ controller.abort();
557
+ reject(new Error("Cancelled"));
558
+ };
559
+ signal?.addEventListener("abort", onAbort, { once: true });
560
+ if (signal?.aborted)
561
+ onAbort();
562
+ })
563
+ ]);
564
+ checkDeadline();
565
+ return result;
566
+ } finally {
567
+ clearTimeout(timer);
568
+ if (onAbort)
569
+ signal?.removeEventListener("abort", onAbort);
570
+ controller.abort();
571
+ }
572
+ }
573
+ async function getInfo(ctx, sessionID, lookup) {
574
+ const info = record2(await ctx.session.get({ sessionID }, { signal: lookup.signal }));
575
+ lookup.check();
576
+ if (info.id !== sessionID)
577
+ throw new Error("Target mismatch");
578
+ return info;
579
+ }
580
+ async function decide(ctx, sessionID, lookup, now, existing) {
581
+ const info = existing ?? await getInfo(ctx, sessionID, lookup);
582
+ timestamp(record2(info.time).created, now());
583
+ if (record2(info.model ?? {}).providerID === "openai")
584
+ return { status: "exempt_openai" };
585
+ if (info.parentID === undefined)
586
+ return { status: "exempt_primary" };
587
+ if (typeof info.parentID !== "string" || !info.parentID)
588
+ throw new Error("Invalid parent");
589
+ const context = await ctx.session.context({ sessionID }, { signal: lookup.signal });
590
+ lookup.check();
591
+ const checkedAt = now();
592
+ if (!Number.isFinite(checkedAt) || checkedAt < 0)
593
+ throw new Error("Invalid clock");
594
+ const activityAgeMs = checkedAt - lastActivity(info, context, checkedAt);
595
+ return { status: activityAgeMs > MAX_INACTIVITY_MS ? "stale" : "allowed", activityAgeMs };
596
+ }
597
+ async function registerCacheProtect(ctx, runtime = {}) {
598
+ const now = runtime.now ?? Date.now;
599
+ async function guard(sessionID) {
600
+ if (typeof sessionID !== "string" || sessionID.length === 0) {
601
+ throw new Tool2.Error({ message: `Cache protection could not verify the target session. ${HANDOFF}` });
602
+ }
603
+ let decision;
604
+ try {
605
+ decision = await boundedLookup((lookup) => decide(ctx, sessionID, lookup, now));
606
+ } catch {
607
+ throw new Tool2.Error({ message: `Cache protection could not verify the target session's activity; further delivery or model dispatch was blocked. ${HANDOFF}` });
608
+ }
609
+ if (decision.status === "stale") {
610
+ throw new Tool2.Error({ message: `Cache protection blocked this subagent: last meaningful activity was more than 295 seconds ago. ${HANDOFF}` });
611
+ }
612
+ }
613
+ const tool = await ctx.tool.hook("execute.before", async (event) => {
614
+ if (event.tool !== "subagent")
615
+ return;
616
+ if (!event.input || typeof event.input !== "object" || Array.isArray(event.input))
617
+ return;
618
+ const input = event.input;
619
+ if (input.sessionID !== undefined)
620
+ await guard(input.sessionID);
621
+ });
622
+ const registrations = [tool];
623
+ const dispose = async () => {
624
+ await Promise.all(registrations.map((registration) => registration.dispose()));
625
+ };
626
+ try {
627
+ for (const name of ["prompt", "context", "compaction"]) {
628
+ registrations.push(await ctx.session.hook(name, (event) => guard(event.sessionID)));
629
+ }
630
+ return dispose;
631
+ } catch (error) {
632
+ await dispose();
633
+ throw error;
634
+ }
635
+ }
636
+ async function registerCacheProtectStatus(ctx, runtime = {}) {
637
+ const registration = await ctx.tool.transform((editor) => {
638
+ editor.add({
639
+ name: "cache_protect_status",
640
+ description: "Read-only freshness snapshot for this session or a verified descendant. Not an admission reservation.",
641
+ input: { type: "object", properties: { sessionID: { type: "string", minLength: 1, description: "Optional existing session ID; defaults to the caller." } }, additionalProperties: false },
642
+ output: { type: "object", properties: { status: { type: "string", enum: ["allowed", "stale", "exempt_openai", "exempt_primary", "unverifiable"] }, policy: { type: "string" }, cutoffMs: { type: "number" }, activityAgeMs: { type: "number" }, guidance: { type: "string" } }, required: ["status", "policy", "cutoffMs"], additionalProperties: false },
643
+ options: { codemode: true },
644
+ execute: async (input, context) => {
645
+ const base = { policy: "durable_meaningful_activity", cutoffMs: MAX_INACTIVITY_MS };
646
+ try {
647
+ const args = record2(input);
648
+ if (Object.keys(args).some((key) => key !== "sessionID"))
649
+ throw new Error("Invalid input");
650
+ const caller = context.sessionID;
651
+ const target = args.sessionID === undefined ? caller : args.sessionID;
652
+ if (typeof caller !== "string" || !caller || typeof target !== "string" || !target)
653
+ throw new Error("Invalid session");
654
+ const decision = await boundedLookup(async (lookup) => {
655
+ let current = target;
656
+ let targetInfo;
657
+ const seen = new Set;
658
+ if (target !== caller) {
659
+ let verified = false;
660
+ for (let depth = 0;depth < MAX_ANCESTRY_DEPTH; depth++) {
661
+ if (seen.has(current))
662
+ throw new Error("Cyclic ancestry");
663
+ seen.add(current);
664
+ const info = await getInfo(ctx, current, lookup);
665
+ targetInfo ??= info;
666
+ if (typeof info.parentID !== "string" || !info.parentID)
667
+ throw new Error("Not a descendant");
668
+ if (info.parentID === caller) {
669
+ verified = true;
670
+ break;
671
+ }
672
+ current = info.parentID;
673
+ }
674
+ if (!verified)
675
+ throw new Error("Ancestry depth exceeded");
676
+ }
677
+ return decide(ctx, target, lookup, runtime.now ?? Date.now, targetInfo);
678
+ }, context.signal);
679
+ return { output: { ...base, ...decision, ...decision.status === "stale" ? { guidance: HANDOFF } : {} } };
680
+ } catch {
681
+ return { output: { ...base, status: "unverifiable", guidance: HANDOFF } };
682
+ }
683
+ }
684
+ });
685
+ });
686
+ return () => registration.dispose();
687
+ }
688
+ var src_default = Plugin.define({ id: "hffmnnj.cache-protect", setup: async (ctx) => {
689
+ const safety = await registerServerSafety(ctx);
690
+ let cache;
691
+ try {
692
+ cache = await registerCacheProtect(ctx);
693
+ const status = await registerCacheProtectStatus(ctx);
694
+ const disposeCache = cache;
695
+ return async () => {
696
+ await Promise.all([status(), disposeCache(), safety()]);
697
+ };
698
+ } catch (error) {
699
+ await Promise.all([...cache ? [cache()] : [], safety()]);
700
+ throw error;
701
+ }
702
+ } });
703
+ export {
704
+ MAX_INACTIVITY_MS,
705
+ src_default as default,
706
+ registerCacheProtect,
707
+ registerCacheProtectStatus
708
+ };
package/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ // V2 2.0.25 discovers this directory entrypoint, not package exports alone.
2
+ export { default } from "./dist/index.js?revision=89e870d2d1a1619d01b631fc3a59addd18acbd35193334626e0310af27fe5dbc";
package/package.json CHANGED
@@ -1,6 +1,35 @@
1
1
  {
2
2
  "name": "hffmnnj.cache-protect",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "exports": { ".": "./dist/index.js" },
6
+ "files": [
7
+ "dist/index.js",
8
+ "index.ts",
9
+ "README.md",
10
+ "assets/banner.png"
11
+ ],
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/hffmnnj/opencode-cache-protect.git"
15
+ },
16
+ "publishConfig": { "access": "public" },
17
+ "dependencies": {
18
+ "@opencode/ai": "2.0.32",
19
+ "@opencode/plugin": "2.0.25"
20
+ },
21
+ "devDependencies": {
22
+ "@opencode/sdk": "2.0.25",
23
+ "@types/bun": "1.3.5",
24
+ "typescript": "5.9.3"
25
+ },
26
+ "scripts": {
27
+ "typecheck": "tsc --noEmit",
28
+ "build": "bun build ./src/index.ts --outfile ./dist/index.js --target bun --external '@opencode/*'",
29
+ "build:prompt-cache": "bun ./scripts/build-prompt-cache.ts",
30
+ "prepack": "bun run build",
31
+ "test": "bun test tests/unit",
32
+ "test:host": "bun test tests/host",
33
+ "check": "bun run typecheck && bun run test && bun run build && bun run build:prompt-cache"
34
+ }
35
+ }