ohmyjev 0.2.0 → 0.3.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 +40 -18
- package/adapters/omp.ts +33 -19
- package/adapters/pi.ts +32 -18
- package/adapters/shared.ts +42 -26
- package/hooks/policy.ts +48 -4
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
<img alt="Claude Code 2.1.287+" src="https://img.shields.io/badge/Claude_Code-2.1.287%2B-d97757">
|
|
17
17
|
<img alt="Decided by Jev" src="https://img.shields.io/badge/decided_by-Jev-101315">
|
|
18
18
|
<img alt="pi and omp" src="https://img.shields.io/badge/also_for-pi_·_omp-86a893">
|
|
19
|
+
<a href="https://www.npmjs.com/package/ohmyjev"><img alt="npm" src="https://img.shields.io/npm/v/ohmyjev?color=de6145"></a>
|
|
19
20
|
<img alt="MIT license" src="https://img.shields.io/badge/license-MIT-798186">
|
|
20
21
|
</p>
|
|
21
22
|
|
|
@@ -48,17 +49,17 @@ Then start Claude Code and run:
|
|
|
48
49
|
|
|
49
50
|
```
|
|
50
51
|
/plugin install ohmyjev --marketplace Novacon/ohmyjev
|
|
51
|
-
/
|
|
52
|
+
/omj
|
|
52
53
|
```
|
|
53
54
|
|
|
54
|
-
If `/
|
|
55
|
+
If `/omj` ends with `key: env TYPESAFE_API_KEY · typesafe · jev-1.13.0`, you're set.
|
|
55
56
|
|
|
56
57
|
## What you get
|
|
57
58
|
|
|
58
59
|
| Feature | What it does |
|
|
59
60
|
|---|---|
|
|
60
61
|
| **Bash gate** | Denies a command when Jev rates it irreversible at 0.6 or more, or destructive at 0.7 or more. |
|
|
61
|
-
| **Write gate** | Denies writes outside the repo and `allowPaths`, and writes that contain a real credential. The path check is plain code, not Jev, and it follows symlinks the same way the OS does. |
|
|
62
|
+
| **Write gate** | Denies writes outside the repo and `allowPaths`, and writes that contain a real credential. The repo is every worktree of the one the session started in, plus every worktree of whichever repo the agent has moved into. The path check is plain code, not Jev, and it follows symlinks the same way the OS does. |
|
|
62
63
|
| **Exfil gate** | Denies a WebFetch or MCP call when Jev rates it 0.7 or more for sending your local data, files or credentials out. |
|
|
63
64
|
| **Policies** | Every gate also checks the call against your own rules in the `policies` setting. |
|
|
64
65
|
| **Injection screen** | When output from Bash, WebFetch, an MCP tool, or a Read outside the repo has instructions aimed at the model, it adds a note telling the model to treat that output as data. |
|
|
@@ -66,7 +67,7 @@ If `/jev` ends with `key: env TYPESAFE_API_KEY · typesafe · jev-1.13.0`, you'r
|
|
|
66
67
|
| **Router** | Asks Jev once per request for a tier, an effort level and a risk score. Then it raises or lowers effort and picks the model for general-purpose subagents. With no key it falls back to Claude Code's built-in classifier, which reports no confidence, so then it only ever routes up. |
|
|
67
68
|
| **Auto-compact** | When the task changes after a finished step and the context is at least 40% full, it compacts the conversation. The summary keeps the new request in full. |
|
|
68
69
|
| **`ask_jev`** | A tool the model can use to ask Jev about repo files or text without loading them into its own context. |
|
|
69
|
-
| **`/
|
|
70
|
+
| **`/omj`** | Shows this session's Jev calls, errors, cost, median latency and denies, plus where the key comes from. It never prints the key itself. `/omj stats` opens a dashboard of every session in your browser. `/omj off` and `/omj on` switch ohmyjev for the session. `/ohmyjev` is the same command. |
|
|
70
71
|
|
|
71
72
|
### How a tool call goes through it
|
|
72
73
|
|
|
@@ -120,11 +121,11 @@ You've got three options, in the order ohmyjev looks for them:
|
|
|
120
121
|
1. **The settings screen.** During the install, ohmyjev shows a screen with its options, including the TypeSafe
|
|
121
122
|
key. Claude Code keeps it in secure storage, not in a settings file.
|
|
122
123
|
2. **`TYPESAFE_API_KEY`** in your environment, for example in `~/.zshrc`.
|
|
123
|
-
3. **`OPENROUTER_API_KEY`** in your environment. ohmyjev then calls Jev through OpenRouter as `~typesafe/
|
|
124
|
+
3. **`OPENROUTER_API_KEY`** in your environment. ohmyjev then calls Jev through OpenRouter as `~typesafe/omj-latest`.
|
|
124
125
|
|
|
125
126
|
### 4. Check it works
|
|
126
127
|
|
|
127
|
-
Start a session and run `/
|
|
128
|
+
Start a session and run `/omj`. The last line tells you where the key came from:
|
|
128
129
|
|
|
129
130
|
```
|
|
130
131
|
key: env TYPESAFE_API_KEY · typesafe · jev-1.13.0
|
|
@@ -137,17 +138,17 @@ Code's built-in classifier (up only), and the status under the prompt shows `jev
|
|
|
137
138
|
|
|
138
139
|
The same checks run in [pi](https://pi.dev) and [omp](https://github.com/can1357/oh-my-pi) as a native extension.
|
|
139
140
|
They share the decision logic and the key lookup (`TYPESAFE_API_KEY`, then `OPENROUTER_API_KEY`) with the Claude Code
|
|
140
|
-
plugin, and write to the same `~/.ohmyjev` logs, so `/
|
|
141
|
+
plugin, and write to the same `~/.ohmyjev` logs, so `/omj` and the statusline segment work the same way.
|
|
141
142
|
|
|
142
143
|
### omp
|
|
143
144
|
|
|
144
145
|
```bash
|
|
145
|
-
omp plugin install
|
|
146
|
+
omp plugin install ohmyjev
|
|
146
147
|
```
|
|
147
148
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
[Settings](#settings):
|
|
149
|
+
That installs the npm package; `omp plugin install github:Novacon/ohmyjev` installs straight from GitHub instead. Or use
|
|
150
|
+
the marketplace this repo already serves: `/marketplace add Novacon/ohmyjev`, then `/marketplace install ohmyjev@ohmyjev`.
|
|
151
|
+
Restart the session after installing. Settings use the names in [Settings](#settings):
|
|
151
152
|
|
|
152
153
|
```bash
|
|
153
154
|
omp plugin config list ohmyjev
|
|
@@ -159,9 +160,11 @@ The router's tiers default to omp's own model roles: `@smol`, `@default` and `@s
|
|
|
159
160
|
### pi
|
|
160
161
|
|
|
161
162
|
```bash
|
|
162
|
-
pi install
|
|
163
|
+
pi install npm:ohmyjev
|
|
163
164
|
```
|
|
164
165
|
|
|
166
|
+
Or straight from GitHub: `pi install git:github.com/Novacon/ohmyjev`.
|
|
167
|
+
|
|
165
168
|
pi has no settings screen for extensions, so put yours under an `ohmyjev` key in `~/.pi/agent/settings.json`, or in
|
|
166
169
|
`.pi/settings.json` for one project (project values win):
|
|
167
170
|
|
|
@@ -198,9 +201,9 @@ This block is final. Do not try to work around it with another command, another
|
|
|
198
201
|
or an encoding that does the same thing. Stop and tell the user what was blocked and why.
|
|
199
202
|
```
|
|
200
203
|
|
|
201
|
-
### `/
|
|
204
|
+
### `/omj`
|
|
202
205
|
|
|
203
|
-
Run it any time to see what this session has been up to. Here's an example:
|
|
206
|
+
Run it any time to see what this session has been up to (`/ohmyjev` does the same). Here's an example:
|
|
204
207
|
|
|
205
208
|
```
|
|
206
209
|
jev ✓23 ⛔1 ↑opus/high
|
|
@@ -246,6 +249,18 @@ said, then what the router did with it:
|
|
|
246
249
|
[ohmyjev] main loop kept opus/medium, wanted opus/low (confidence 0.38)
|
|
247
250
|
```
|
|
248
251
|
|
|
252
|
+
### `/omj stats`
|
|
253
|
+
|
|
254
|
+
Writes every session's decisions into one page at `~/.ohmyjev/dashboard.html` and opens it: calls, denies, flags, cost
|
|
255
|
+
and latency, a 30-day chart, a table per tool and per session, and the latest denies with their reasons. It's a plain
|
|
256
|
+
file, read from the logs, so nothing on it leaves the machine (the page fetches its fonts from ohmyjev.xyz, and falls
|
|
257
|
+
back to system fonts offline).
|
|
258
|
+
|
|
259
|
+
### `/omj off`
|
|
260
|
+
|
|
261
|
+
Turns every gate, screen, the done-check and the router off for this session, and the status shows `jev off`.
|
|
262
|
+
`/omj on` brings them back. To start sessions off by default, turn off the `enabled` setting.
|
|
263
|
+
|
|
249
264
|
The last line is the router declining to act: it wanted to spend less, but 0.38 is under the 0.6 it takes to move
|
|
250
265
|
down. In a `claude -p` or SDK run the same lines arrive as `ui_log` messages and in the debug log. Turn them off with
|
|
251
266
|
`logDecisions`.
|
|
@@ -272,11 +287,12 @@ if jev:
|
|
|
272
287
|
|
|
273
288
|
## Settings
|
|
274
289
|
|
|
275
|
-
Run `/
|
|
290
|
+
Run `/omj settings` to see every setting's current value (the key stays hidden). To change them, run
|
|
276
291
|
`/plugin configure ohmyjev@ohmyjev` inside Claude Code, then `/reload-plugins`.
|
|
277
292
|
|
|
278
293
|
| Setting | Default | What it changes |
|
|
279
294
|
|---|---|---|
|
|
295
|
+
| `enabled` | on | The whole mod. Off lets every call through; `/omj on` and `/omj off` change it for one session. |
|
|
280
296
|
| `bashGate`, `writeGate`, `exfilGate` | on | Turns each gate on or off. |
|
|
281
297
|
| `injectionScreen`, `screenReads` | on | Screens tool output, including Reads from outside the repo. |
|
|
282
298
|
| `doneCheck` | on | Pushes back on an unverified "done". |
|
|
@@ -286,7 +302,7 @@ Run `/jev settings` to see every setting's current value (the key stays hidden).
|
|
|
286
302
|
| `autoCompact` | on | Compacts when the task changes. `compactMinPercent` (40) sets how full the context has to be first. |
|
|
287
303
|
| `askJev` | on | Gives the model the `ask_jev` tool. |
|
|
288
304
|
| `policies` | empty | Your own rules, separated by `;`. |
|
|
289
|
-
| `allowPaths` | `~/.claude;$TMPDIR;/tmp` | Places outside the repo where writes are allowed. ohmyjev ignores any entry with `..` in it. |
|
|
305
|
+
| `allowPaths` | `~/.claude;$TMPDIR;/tmp` | Places outside the repo where writes are allowed. ohmyjev ignores any entry with `..` in it. You don't need it for a sibling worktree or another git repo the agent `cd`s into: those count as the repo. |
|
|
290
306
|
| `fastModel`, `balancedModel`, `deepModel` | `claude-haiku-5-5`, `claude-sonnet-5-5`, `claude-opus-5-5` | The model id for each router tier. |
|
|
291
307
|
| `jevModel` | `jev-1.13.0` | The Jev model asked through TypeSafe. |
|
|
292
308
|
| `timeoutMs` | 1500 | How long a gate, the screen or the router waits for Jev before letting the call through. |
|
|
@@ -327,6 +343,12 @@ have set up, through pi's own provider. Logs and session files stay in `~/.ohmyj
|
|
|
327
343
|
```bash
|
|
328
344
|
claude plugin update ohmyjev@ohmyjev # then restart Claude Code
|
|
329
345
|
claude plugin uninstall ohmyjev@ohmyjev
|
|
346
|
+
|
|
347
|
+
omp plugin upgrade ohmyjev # omp; restart the session
|
|
348
|
+
omp plugin uninstall ohmyjev
|
|
349
|
+
|
|
350
|
+
pi update npm:ohmyjev # pi
|
|
351
|
+
pi remove npm:ohmyjev
|
|
330
352
|
```
|
|
331
353
|
|
|
332
354
|
## Troubleshooting
|
|
@@ -335,9 +357,9 @@ claude plugin uninstall ohmyjev@ohmyjev
|
|
|
335
357
|
Claude Code, or enter the key with `/plugin configure ohmyjev@ohmyjev`.
|
|
336
358
|
|
|
337
359
|
**The status says `jev ⚠ down`.** A Jev call failed or took longer than `timeoutMs` (1.5 s) in the last 5 minutes. The
|
|
338
|
-
gates let calls through until Jev answers again. `/
|
|
360
|
+
gates let calls through until Jev answers again. `/omj` shows the error count.
|
|
339
361
|
|
|
340
|
-
**Something got blocked that shouldn't have.** `/
|
|
362
|
+
**Something got blocked that shouldn't have.** `/omj` shows the reason and Jev's numbers. Raise that gate's threshold
|
|
341
363
|
with `/plugin configure ohmyjev@ohmyjev`, or turn the gate off. Local MCP tools sometimes trip the exfil gate, and `exfilGate` is the switch for
|
|
342
364
|
that.
|
|
343
365
|
|
package/adapters/omp.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
import { readFile } from 'node:fs/promises'
|
|
5
5
|
import {
|
|
6
6
|
BASH_Q, DEFAULTS, EXFIL_Q, ROUTE_Q, SCREEN_Q, STOP_Q, SWITCHED_Q, WRITE_Q,
|
|
7
|
-
clip, decideRoute, denyText, gateBash, gateExfil, gateWrite, jevRouteLine, judgeStop,
|
|
7
|
+
clip, decideRoute, denyText, gateBash, gateExfil, gateWrite, jevRouteLine, judgeStop, pathDenyText,
|
|
8
|
+
OMJ_HELP, OMJ_OFF, OMJ_ON, settingsRows, toggleArg,
|
|
8
9
|
keepInstructions, modelOf, readConfig, routeStep, screen, stepLine, withPolicies, withPolicyQ,
|
|
9
10
|
type Config, type Route,
|
|
10
11
|
} from '../hooks/policy.ts'
|
|
@@ -267,7 +268,7 @@ export function createExtension(options: OmpExtensionOptions = {}): (pi: OmpApi)
|
|
|
267
268
|
const status = (): void => {
|
|
268
269
|
try {
|
|
269
270
|
if (!latestContext) return
|
|
270
|
-
latestContext.ui.setStatus('ohmyjev', c.statusLine && session ? session.status() : undefined)
|
|
271
|
+
latestContext.ui.setStatus('ohmyjev', c.statusLine && session ? session.status(Date.now(), c.enabled) : undefined)
|
|
271
272
|
} catch {}
|
|
272
273
|
}
|
|
273
274
|
|
|
@@ -460,7 +461,7 @@ export function createExtension(options: OmpExtensionOptions = {}): (pi: OmpApi)
|
|
|
460
461
|
pi.on('tool_call', async (rawEvent, ctx) => {
|
|
461
462
|
try {
|
|
462
463
|
touch(ctx)
|
|
463
|
-
if (!session) return
|
|
464
|
+
if (!session || !c.enabled) return
|
|
464
465
|
const event = rawEvent as ToolCall
|
|
465
466
|
const tool = event.toolName
|
|
466
467
|
const input = object(event.input)
|
|
@@ -497,9 +498,8 @@ export function createExtension(options: OmpExtensionOptions = {}): (pi: OmpApi)
|
|
|
497
498
|
}
|
|
498
499
|
for (const path of paths) {
|
|
499
500
|
if (!sessionLocalWrite(path) && !(await pathAllowed(path, ctx.cwd, root, c, env))) {
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
return { block: true, reason: denyText(reason) }
|
|
501
|
+
session.denyByCode(tool, `${path} is outside the repo and allowPaths`)
|
|
502
|
+
return { block: true, reason: pathDenyText(path) }
|
|
503
503
|
}
|
|
504
504
|
}
|
|
505
505
|
const path = paths.join('; ')
|
|
@@ -533,7 +533,7 @@ export function createExtension(options: OmpExtensionOptions = {}): (pi: OmpApi)
|
|
|
533
533
|
pi.on('tool_result', async (rawEvent, ctx) => {
|
|
534
534
|
try {
|
|
535
535
|
touch(ctx)
|
|
536
|
-
if (!session || !c.injectionScreen) return
|
|
536
|
+
if (!session || !c.enabled || !c.injectionScreen) return
|
|
537
537
|
const event = rawEvent as ToolResult
|
|
538
538
|
const tool = event.toolName
|
|
539
539
|
const input = object(event.input)
|
|
@@ -560,6 +560,7 @@ export function createExtension(options: OmpExtensionOptions = {}): (pi: OmpApi)
|
|
|
560
560
|
touch(ctx)
|
|
561
561
|
if (!session || ctx.agent.kind !== 'main') return
|
|
562
562
|
restoreUserThinking(ctx)
|
|
563
|
+
if (!c.enabled) return
|
|
563
564
|
if (!(c.doneCheck || c.autoCompact)) return
|
|
564
565
|
const event = rawEvent as unknown as StopEvent
|
|
565
566
|
if (event.stop_hook_active) {
|
|
@@ -605,7 +606,7 @@ export function createExtension(options: OmpExtensionOptions = {}): (pi: OmpApi)
|
|
|
605
606
|
pi.on('before_agent_start', async (rawEvent, ctx) => {
|
|
606
607
|
try {
|
|
607
608
|
touch(ctx)
|
|
608
|
-
if (ctx.agent.kind !== 'main') return
|
|
609
|
+
if (ctx.agent.kind !== 'main' || !c.enabled) return
|
|
609
610
|
userEffort = userThinking(ctx)
|
|
610
611
|
const prompt = typeof rawEvent.prompt === 'string' ? rawEvent.prompt : ''
|
|
611
612
|
if (!prompt.trim()) return
|
|
@@ -650,17 +651,30 @@ export function createExtension(options: OmpExtensionOptions = {}): (pi: OmpApi)
|
|
|
650
651
|
}
|
|
651
652
|
})
|
|
652
653
|
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
654
|
+
/** /omj and /ohmyjev: the report, `settings`, or `on`/`off` for this session (the enabled setting is the default). */
|
|
655
|
+
const command = async (args: string, ctx: HostContext): Promise<void> => {
|
|
656
|
+
try {
|
|
657
|
+
touch(ctx)
|
|
658
|
+
const a = args.trim()
|
|
659
|
+
const enabled = toggleArg(a)
|
|
660
|
+
if (enabled !== undefined) {
|
|
661
|
+
c = { ...c, enabled }
|
|
662
|
+
if (!enabled) {
|
|
663
|
+
route = undefined
|
|
664
|
+
compactPending = false
|
|
665
|
+
}
|
|
666
|
+
status()
|
|
667
|
+
ctx.ui.notify(enabled ? OMJ_ON : OMJ_OFF, 'info')
|
|
668
|
+
} else if (a === 'stats') ctx.ui.notify(session ? await session.dashboard('omp') : 'ohmyjev: no active session', 'info')
|
|
669
|
+
else if (a === 'settings') ctx.ui.notify(settingsRows(c).join('\n'), 'info')
|
|
670
|
+
else if (a) ctx.ui.notify(OMJ_HELP, 'info')
|
|
671
|
+
else ctx.ui.notify(session ? await session.report(c.enabled) : 'ohmyjev: no active session', 'info')
|
|
672
|
+
} catch {
|
|
673
|
+
try { ctx.ui.notify('ohmyjev: report unavailable', 'error') } catch {}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
for (const name of ['omj', 'ohmyjev'])
|
|
677
|
+
pi.registerCommand(name, { description: "ohmyjev: this session's Jev calls, denies, cost and key source; stats opens the dashboard; on/off for this session", handler: command })
|
|
664
678
|
}
|
|
665
679
|
}
|
|
666
680
|
|
package/adapters/pi.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises'
|
|
2
2
|
import {
|
|
3
3
|
BASH_Q, EXFIL_Q, ROUTE_Q, SCREEN_Q, STOP_Q, SWITCHED_Q, WRITE_Q,
|
|
4
|
-
TIER_LABELS, builtinRoute, clip, decideRoute, denyText, gateBash, gateExfil, gateWrite, jevRouteLine, judgeStop,
|
|
4
|
+
TIER_LABELS, builtinRoute, clip, decideRoute, denyText, gateBash, gateExfil, gateWrite, jevRouteLine, judgeStop, pathDenyText,
|
|
5
|
+
OMJ_HELP, OMJ_OFF, OMJ_ON, settingsRows, toggleArg,
|
|
5
6
|
keepInstructions, readConfig, routeStep, screen, stepLine, withPolicies, withPolicyQ,
|
|
6
7
|
type Config, type Route,
|
|
7
8
|
} from '../hooks/policy.ts'
|
|
@@ -264,7 +265,7 @@ export function createExtension(options: PiAdapterOptions = {}) {
|
|
|
264
265
|
const status = (): void => {
|
|
265
266
|
if (!c.statusLine || !session || !latestCtx) return
|
|
266
267
|
try {
|
|
267
|
-
latestCtx.ui.setStatus('ohmyjev', session.status())
|
|
268
|
+
latestCtx.ui.setStatus('ohmyjev', session.status(Date.now(), c.enabled))
|
|
268
269
|
} catch {}
|
|
269
270
|
}
|
|
270
271
|
const say = (ctx: PiContext, line: string): void => {
|
|
@@ -348,17 +349,28 @@ export function createExtension(options: PiAdapterOptions = {}) {
|
|
|
348
349
|
askRegistered = true
|
|
349
350
|
}
|
|
350
351
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
352
|
+
/** /omj and /ohmyjev: the report, `settings`, or `on`/`off` for this session (the enabled setting is the default). */
|
|
353
|
+
const command = async (args: string, ctx: PiContext): Promise<void> => {
|
|
354
|
+
try {
|
|
355
|
+
const x = await ensureSession(ctx)
|
|
356
|
+
const a = args.trim()
|
|
357
|
+
const enabled = toggleArg(a)
|
|
358
|
+
if (enabled !== undefined) {
|
|
359
|
+
c = { ...c, enabled }
|
|
360
|
+
if (!enabled) {
|
|
361
|
+
route = undefined
|
|
362
|
+
compactPending = false
|
|
363
|
+
}
|
|
364
|
+
status()
|
|
365
|
+
ctx.ui.notify(enabled ? OMJ_ON : OMJ_OFF, 'info')
|
|
366
|
+
} else if (a === 'stats') ctx.ui.notify(await x.dashboard('pi'), 'info')
|
|
367
|
+
else ctx.ui.notify(a === 'settings' ? settingsRows(c).join('\n') : a ? OMJ_HELP : await x.report(c.enabled), 'info')
|
|
368
|
+
} catch {
|
|
369
|
+
ctx.ui.notify('ohmyjev report unavailable', 'warning')
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
for (const name of ['omj', 'ohmyjev'])
|
|
373
|
+
pi.registerCommand(name, { description: "ohmyjev: this session's Jev calls, denies, cost and key source; stats opens the dashboard; on/off for this session", handler: command })
|
|
362
374
|
|
|
363
375
|
pi.on('session_start', guarded(async (_event, ctx) => {
|
|
364
376
|
reset()
|
|
@@ -373,7 +385,7 @@ export function createExtension(options: PiAdapterOptions = {}) {
|
|
|
373
385
|
? `ready: Jev via ${key.provider} (${key.model}, key from ${key.source})`
|
|
374
386
|
: `no Jev key: gates and screens let every call through${c.routeWithoutKey ? '; routing uses Pi\'s cheapest available model classifier, up only' : ''}. Set TYPESAFE_API_KEY or the apiKey setting.`,
|
|
375
387
|
)
|
|
376
|
-
ctx.ui.setStatus('ohmyjev', c.statusLine ? x.status() : undefined)
|
|
388
|
+
ctx.ui.setStatus('ohmyjev', c.statusLine ? x.status(Date.now(), c.enabled) : undefined)
|
|
377
389
|
}))
|
|
378
390
|
|
|
379
391
|
pi.on('thinking_level_select', guarded<ThinkingLevelSelectEvent, void>((event) => {
|
|
@@ -391,6 +403,7 @@ export function createExtension(options: PiAdapterOptions = {}) {
|
|
|
391
403
|
|
|
392
404
|
pi.on('before_agent_start', guarded<BeforeAgentStartEvent, void>(async (event, ctx) => {
|
|
393
405
|
latestCtx = ctx
|
|
406
|
+
if (!c.enabled) return
|
|
394
407
|
const text = event.prompt
|
|
395
408
|
if (!text.trim()) return
|
|
396
409
|
restoreThinking()
|
|
@@ -414,6 +427,7 @@ export function createExtension(options: PiAdapterOptions = {}) {
|
|
|
414
427
|
|
|
415
428
|
pi.on('tool_call', guarded<ToolCallEvent, { block: true; reason: string }>(async (event, ctx) => {
|
|
416
429
|
latestCtx = ctx
|
|
430
|
+
if (!c.enabled) return
|
|
417
431
|
const x = await ensureSession(ctx)
|
|
418
432
|
const tool = event.toolName
|
|
419
433
|
const input = event.input
|
|
@@ -430,9 +444,8 @@ export function createExtension(options: PiAdapterOptions = {}) {
|
|
|
430
444
|
if ((tool === 'write' || tool === 'edit') && c.writeGate) {
|
|
431
445
|
const path = arg(input, 'path')
|
|
432
446
|
if (!(await pathAllowed(path, ctx.cwd, root, c, env))) {
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
return { block: true, reason: denyText(reason) }
|
|
447
|
+
x.denyByCode(tool, `${path} is outside the repo and allowPaths`)
|
|
448
|
+
return { block: true, reason: pathDenyText(path) }
|
|
436
449
|
}
|
|
437
450
|
const content = tool === 'write' ? arg(input, 'content') : json(input.edits)
|
|
438
451
|
const d = await x.decide('tool.call', tool, withPolicies({ path, content: clip(content, CLIP) }, c), withPolicyQ(WRITE_Q, c))
|
|
@@ -458,6 +471,7 @@ export function createExtension(options: PiAdapterOptions = {}) {
|
|
|
458
471
|
|
|
459
472
|
pi.on('tool_result', guarded<ToolResultEvent, { content: Content[]; structuredContent?: unknown }>(async (event, ctx) => {
|
|
460
473
|
latestCtx = ctx
|
|
474
|
+
if (!c.enabled) return
|
|
461
475
|
const tool = event.toolName
|
|
462
476
|
const shell = tool === 'bash' || tool === 'powershell'
|
|
463
477
|
if (!c.injectionScreen || (event.isError && !shell)) return
|
|
@@ -482,7 +496,7 @@ export function createExtension(options: PiAdapterOptions = {}) {
|
|
|
482
496
|
|
|
483
497
|
pi.on('agent_before_settle', guarded<BoundaryEvent, { entries: Array<Record<string, unknown>>; continue: true }>(async (event, ctx) => {
|
|
484
498
|
latestCtx = ctx
|
|
485
|
-
if (event.outcome !== 'completed' || event.continue || !event.context.canContinue || !(c.doneCheck || c.autoCompact)) return
|
|
499
|
+
if (!c.enabled || event.outcome !== 'completed' || event.continue || !event.context.canContinue || !(c.doneCheck || c.autoCompact)) return
|
|
486
500
|
const { count, state } = messagesForStop(event.context.contextMessages)
|
|
487
501
|
if (!count || count === pushedBackAt) return
|
|
488
502
|
liveRequest = state.current_request
|
package/adapters/shared.ts
CHANGED
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
* under pi (Node) and omp (Bun). Every decision stays in hooks/policy.ts; every failure passes the call through.
|
|
5
5
|
*/
|
|
6
6
|
import { execFile } from 'node:child_process'
|
|
7
|
-
import { appendFile, chmod, lstat, mkdir, readFile, realpath, writeFile } from 'node:fs/promises'
|
|
8
|
-
import {
|
|
7
|
+
import { appendFile, chmod, lstat, mkdir, readFile, readdir, realpath, writeFile } from 'node:fs/promises'
|
|
8
|
+
import { dashboardHtml } from '../hooks/dashboard.ts'
|
|
9
9
|
import { ENDPOINTS, JevError, parseReply, pickKey, type Key } from '../hooks/jev.ts'
|
|
10
10
|
import {
|
|
11
|
-
EMPTY_SESSION, absolute, buildQuestion, clip, expandRoot, isUnder, normalize,
|
|
12
|
-
summarize, type Answers, type Config, type LogEntry, type Questions, type SessionState, type Verdict,
|
|
11
|
+
DASHBOARD, EMPTY_SESSION, absolute, buildQuestion, clip, expandRoot, hostPath, isUnder, normalize, parseLog, parseWorktrees, rawAbsolute, sanitizeSid,
|
|
12
|
+
splitList, statusText, summarize, type Answers, type Config, type LogEntry, type Questions, type SessionState, type Verdict,
|
|
13
13
|
} from '../hooks/policy.ts'
|
|
14
14
|
|
|
15
15
|
export type Env = Readonly<Record<string, string | undefined>>
|
|
@@ -133,6 +133,20 @@ export class Session {
|
|
|
133
133
|
this.log(d.e)
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/**
|
|
137
|
+
* /omj stats: every session's log as one page at ~/.ohmyjev/dashboard.html, opened with `open` or `xdg-open`
|
|
138
|
+
* (tests pass their own opener). The line to show the user.
|
|
139
|
+
*/
|
|
140
|
+
async dashboard(source: string, open: (path: string) => Promise<boolean> = openInBrowser): Promise<string> {
|
|
141
|
+
await this.ready
|
|
142
|
+
const dir = `${this.dir}/log`
|
|
143
|
+
const names = (await readdir(dir).catch(() => [] as string[])).filter(n => n.endsWith('.jsonl'))
|
|
144
|
+
const logs = await Promise.all(names.map(n => readFile(`${dir}/${n}`, 'utf8').catch(() => '')))
|
|
145
|
+
const path = `${this.dir}/${DASHBOARD}`
|
|
146
|
+
await writeFile(path, dashboardHtml(parseLog(logs.join('\n')), { now: Date.now(), sid: this.sid, source }), { mode: 0o600 })
|
|
147
|
+
return `dashboard: ${path}${(await open(path)) ? ' (opened in your browser)' : ' (open it in a browser)'}`
|
|
148
|
+
}
|
|
149
|
+
|
|
136
150
|
/** A deny decided by code (a path), counted and logged like Jev's. */
|
|
137
151
|
denyByCode(tool: string, reason: string): void {
|
|
138
152
|
this.s.denies++
|
|
@@ -146,23 +160,30 @@ export class Session {
|
|
|
146
160
|
this.save()
|
|
147
161
|
}
|
|
148
162
|
|
|
149
|
-
status(now = Date.now()): string {
|
|
150
|
-
return statusText(keyOf(this.c, this.env) ? this.s : { ...this.s, noKey: true }, now)
|
|
163
|
+
status(now = Date.now(), enabled = true): string {
|
|
164
|
+
return statusText(keyOf(this.c, this.env) ? this.s : { ...this.s, noKey: true }, now, enabled)
|
|
151
165
|
}
|
|
152
166
|
|
|
153
|
-
/** /
|
|
154
|
-
async report(): Promise<string> {
|
|
167
|
+
/** /omj: this session's status and log summary, and where the key comes from (never the key). */
|
|
168
|
+
async report(enabled = true): Promise<string> {
|
|
155
169
|
await this.ready
|
|
156
170
|
const log = await readFile(this.logPath, 'utf8').catch(() => '')
|
|
157
171
|
const k = keyOf(this.c, this.env)
|
|
158
172
|
return [
|
|
159
|
-
statusText(this.s, Date.now()),
|
|
173
|
+
statusText(this.s, Date.now(), enabled),
|
|
160
174
|
summarize(log),
|
|
161
175
|
`key: ${k ? `${k.source} · ${k.provider} · ${k.model}` : 'none (set TYPESAFE_API_KEY or the apiKey setting)'}`,
|
|
162
176
|
].join('\n')
|
|
163
177
|
}
|
|
164
178
|
}
|
|
165
179
|
|
|
180
|
+
/** `open` on macOS, `xdg-open` elsewhere; false when neither works. */
|
|
181
|
+
function openInBrowser(path: string): Promise<boolean> {
|
|
182
|
+
const { promise, resolve } = Promise.withResolvers<boolean>()
|
|
183
|
+
execFile('sh', ['-c', 'open "$0" 2>/dev/null || xdg-open "$0"', path], { timeout: 5000 }, err => resolve(!err))
|
|
184
|
+
return promise
|
|
185
|
+
}
|
|
186
|
+
|
|
166
187
|
// --- paths: code decides, never Jev ---
|
|
167
188
|
|
|
168
189
|
/** The repo root: git's top level from cwd, else cwd itself. */
|
|
@@ -172,6 +193,13 @@ export function repoRoot(cwd: string): Promise<string> {
|
|
|
172
193
|
return promise
|
|
173
194
|
}
|
|
174
195
|
|
|
196
|
+
/** Every checkout of the repo `cwd` is in; none when cwd is not in a repo or git is unavailable. */
|
|
197
|
+
export function worktrees(cwd: string): Promise<string[]> {
|
|
198
|
+
const { promise, resolve } = Promise.withResolvers<string[]>()
|
|
199
|
+
execFile('git', ['-C', cwd, 'worktree', 'list', '--porcelain'], { timeout: 2000 }, (err, stdout) => resolve(err ? [] : parseWorktrees(stdout)))
|
|
200
|
+
return promise
|
|
201
|
+
}
|
|
202
|
+
|
|
175
203
|
/**
|
|
176
204
|
* Where an absolute spelling lands by the OS's rules: each existing component's link followed as it is reached, then
|
|
177
205
|
* `..` from where that link led. Null (deny) for a dangling link or a path that exists yet will not resolve.
|
|
@@ -194,25 +222,13 @@ async function place(p: string): Promise<string | null> {
|
|
|
194
222
|
return cur || '/'
|
|
195
223
|
}
|
|
196
224
|
|
|
197
|
-
|
|
198
|
-
* The local path a pi or omp file tool actually opens: a `file://` URL as a path, and the `@` and stray `:` prefixes
|
|
199
|
-
* both hosts drop. Null for any other `scheme://` target (omp's ssh:// writes to another machine): never a repo path.
|
|
200
|
-
*/
|
|
201
|
-
export function hostPath(p: string): string | null {
|
|
202
|
-
if (/^file:\/\//i.test(p)) {
|
|
203
|
-
try {
|
|
204
|
-
return fileURLToPath(p)
|
|
205
|
-
} catch {
|
|
206
|
-
return null
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(p)) return null
|
|
210
|
-
return /^@[/~]|^:[/~.]/.test(p) ? p.slice(1) : p
|
|
211
|
-
}
|
|
225
|
+
export { hostPath }
|
|
212
226
|
|
|
213
227
|
/**
|
|
214
228
|
* Allowed only when the OS's reading (raw spelling) and a normalizing tool's reading (lexical) both land in a root:
|
|
215
|
-
* the repo,
|
|
229
|
+
* the session's repo, every worktree of the repo `cwd` is in (a sibling worktree, or another project the agent was
|
|
230
|
+
* told to work in), plus allowPaths for writes. Spelled the way the host's tools read it (hostPath); another machine
|
|
231
|
+
* never is.
|
|
216
232
|
*/
|
|
217
233
|
export async function pathAllowed(spelled: string, cwd: string, root: string, c: Config, env: Env, withAllowPaths = true): Promise<boolean> {
|
|
218
234
|
const path = hostPath(spelled)
|
|
@@ -220,7 +236,7 @@ export async function pathAllowed(spelled: string, cwd: string, root: string, c:
|
|
|
220
236
|
const home = env.HOME ?? ''
|
|
221
237
|
const roots: string[] = []
|
|
222
238
|
const extra = withAllowPaths ? splitList(c.allowPaths).map(r => expandRoot(r, home, env.TMPDIR)) : []
|
|
223
|
-
for (const r of [root, ...extra]) {
|
|
239
|
+
for (const r of [root, ...(await worktrees(cwd)), ...extra]) {
|
|
224
240
|
const placed = r ? await place(r) : null
|
|
225
241
|
if (placed) roots.push(placed)
|
|
226
242
|
}
|
package/hooks/policy.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
export const DEFAULTS = {
|
|
9
9
|
apiKey: '',
|
|
10
10
|
jevModel: 'jev-1.13.0',
|
|
11
|
+
enabled: true,
|
|
11
12
|
bashGate: true,
|
|
12
13
|
writeGate: true,
|
|
13
14
|
injectionScreen: true,
|
|
@@ -176,6 +177,10 @@ export const clip = (text: unknown, n: number): string => {
|
|
|
176
177
|
}
|
|
177
178
|
export const sanitizeSid = (sid: string): string => sid.replace(/[^\w-]/g, '') || 'unknown'
|
|
178
179
|
export const denyText = (reason: string): string => `ohmyjev blocked this: ${reason}. ${BLOCK_NOTICE}`
|
|
180
|
+
/** A path deny is a setting, not a hazard: the model must not route around it, but it should tell the user how to widen it. */
|
|
181
|
+
export const pathDenyText = (path: string): string =>
|
|
182
|
+
`ohmyjev blocked this: ${path} is outside the repo and allowPaths. Do not write it another way (a shell redirect, another tool). ` +
|
|
183
|
+
'If the user asked for this location, tell them to add its directory to the allowPaths setting, or to start the session in that repo.'
|
|
179
184
|
|
|
180
185
|
const f2 = (x: number): string => x.toFixed(2)
|
|
181
186
|
const nv = (a: Answers, k: string): number => {
|
|
@@ -373,6 +378,28 @@ export function expandRoot(p: string, home: string, tmpdir: string | undefined):
|
|
|
373
378
|
return absolute(p, '/', home)
|
|
374
379
|
}
|
|
375
380
|
|
|
381
|
+
/**
|
|
382
|
+
* The local path a pi or omp file tool actually opens: a `file://` URL as a path, and the `@` and stray `:` prefixes
|
|
383
|
+
* both hosts drop. Null for any other `scheme://` target (omp's ssh:// writes to another machine): never a repo path.
|
|
384
|
+
*/
|
|
385
|
+
export function hostPath(p: string): string | null {
|
|
386
|
+
if (/^file:\/\//i.test(p)) {
|
|
387
|
+
// as node's fileURLToPath on POSIX: no host but localhost, no encoded `/`; a query or fragment is refused, not dropped
|
|
388
|
+
const path = /^file:\/\/(?:localhost)?(\/[^?#]*)$/i.exec(p)?.[1]
|
|
389
|
+
try {
|
|
390
|
+
return path === undefined || /%2f/i.test(path) ? null : decodeURIComponent(path)
|
|
391
|
+
} catch {
|
|
392
|
+
return null
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(p)) return null
|
|
396
|
+
return /^@[/~]|^:[/~.]/.test(p) ? p.slice(1) : p
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** The worktrees in `git worktree list --porcelain` output: every checkout of the repo counts as the repo. */
|
|
400
|
+
export const parseWorktrees = (porcelain: string): string[] =>
|
|
401
|
+
porcelain.split('\n').filter(l => l.startsWith('worktree ')).map(l => l.slice('worktree '.length).trim()).filter(Boolean)
|
|
402
|
+
|
|
376
403
|
export const isUnder = (target: string, root: string): boolean =>
|
|
377
404
|
target === root || target.startsWith(root.replace(/\/+$/, '') + '/')
|
|
378
405
|
|
|
@@ -381,7 +408,8 @@ export const isUnder = (target: string, root: string): boolean =>
|
|
|
381
408
|
export type SessionState = { calls: number; denies: number; downUntil: number; noKey: boolean; lastRoute: string; compactions: number }
|
|
382
409
|
export const EMPTY_SESSION: SessionState = { calls: 0, denies: 0, downUntil: 0, noKey: false, lastRoute: '', compactions: 0 }
|
|
383
410
|
|
|
384
|
-
export function statusText(s: SessionState, now: number): string {
|
|
411
|
+
export function statusText(s: SessionState, now: number, enabled = true): string {
|
|
412
|
+
if (!enabled) return 'jev off'
|
|
385
413
|
if (s.noKey) return 'jev ⚠ no key'
|
|
386
414
|
if (s.downUntil > now) return 'jev ⚠ down'
|
|
387
415
|
return [`jev ✓${s.calls}`, s.denies ? `⛔${s.denies}` : '', s.lastRoute, s.compactions ? `🗜${s.compactions}` : ''].filter(Boolean).join(' ')
|
|
@@ -402,7 +430,17 @@ export type LogEntry = {
|
|
|
402
430
|
costUsd?: number
|
|
403
431
|
}
|
|
404
432
|
|
|
405
|
-
// --- ask_jev and /
|
|
433
|
+
// --- ask_jev, /omj and /ohmyjev ---
|
|
434
|
+
|
|
435
|
+
export const OMJ_HELP = 'usage: /omj [stats|settings|on|off]. /ohmyjev is the same command.'
|
|
436
|
+
export const DASHBOARD = 'dashboard.html'
|
|
437
|
+
export const OMJ_OFF = 'ohmyjev off for this session: every call passes through. /omj on turns it back. The enabled setting is the default for new sessions.'
|
|
438
|
+
export const OMJ_ON = 'ohmyjev on: gates, screens, done-check and router are back for this session.'
|
|
439
|
+
/** `/omj on|off` as the new enabled value; undefined for any other argument. */
|
|
440
|
+
export const toggleArg = (args: string): boolean | undefined => ({ on: true, off: false })[args.trim() as 'on' | 'off']
|
|
441
|
+
/** /omj settings: every setting's current value, the key only as set or not. */
|
|
442
|
+
export const settingsRows = (c: Config): string[] =>
|
|
443
|
+
Object.entries(c).map(([k, v]) => (k === 'apiKey' ? `apiKey: ${v ? 'set (hidden)' : 'not set'}` : `${k}: ${v === '' ? '(empty)' : String(v)}`))
|
|
406
444
|
|
|
407
445
|
/** The model's ask_jev input as one Jev question, or the error text to hand back. */
|
|
408
446
|
export function buildQuestion(i: { question?: unknown; type?: unknown; options?: unknown }): Question | string {
|
|
@@ -418,14 +456,20 @@ export function buildQuestion(i: { question?: unknown; type?: unknown; options?:
|
|
|
418
456
|
return 'type must be noul, choice or score'
|
|
419
457
|
}
|
|
420
458
|
|
|
421
|
-
/**
|
|
422
|
-
export function
|
|
459
|
+
/** The entries of one or more decision logs; torn lines and blanks are skipped. */
|
|
460
|
+
export function parseLog(log: string): LogEntry[] {
|
|
423
461
|
const rows: LogEntry[] = []
|
|
424
462
|
for (const line of log.split('\n')) {
|
|
425
463
|
try {
|
|
426
464
|
if (line.trim()) rows.push(JSON.parse(line) as LogEntry)
|
|
427
465
|
} catch {}
|
|
428
466
|
}
|
|
467
|
+
return rows
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** /omj's body from a session log: calls, errors, cost, p50, denies per tool, the last 5 denies. */
|
|
471
|
+
export function summarize(log: string): string {
|
|
472
|
+
const rows = parseLog(log)
|
|
429
473
|
const calls = rows.filter(r => r.answers)
|
|
430
474
|
const ms = calls.map(r => r.ms ?? 0).sort((a, b) => a - b)
|
|
431
475
|
const cost = calls.reduce((n, r) => n + (r.costUsd ?? 0), 0)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ohmyjev",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Guardrails for coding agents, decided by TypeSafe's Jev: blocks destructive commands, catches prompt injection, pushes back on an unverified done and routes effort per turn. For Claude Code, pi and omp.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -45,6 +45,11 @@
|
|
|
45
45
|
"description": "Model id sent to the TypeSafe endpoint",
|
|
46
46
|
"default": "jev-1.13.0"
|
|
47
47
|
},
|
|
48
|
+
"enabled": {
|
|
49
|
+
"type": "boolean",
|
|
50
|
+
"description": "Off lets every call through. /omj off and /omj on change it for one session",
|
|
51
|
+
"default": true
|
|
52
|
+
},
|
|
48
53
|
"bashGate": {
|
|
49
54
|
"type": "boolean",
|
|
50
55
|
"description": "Deny irreversible or destructive shell and eval calls",
|