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 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
- /jev
52
+ /omj
52
53
  ```
53
54
 
54
- If `/jev` ends with `key: env TYPESAFE_API_KEY · typesafe · jev-1.13.0`, you're set.
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
- | **`/jev`** | Shows this session's Jev calls, errors, cost, median latency and denies, plus where the key comes from. It never prints the key itself. |
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/jev-latest`.
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 `/jev`. The last line tells you where the key came from:
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 `/jev` and the statusline segment work the same way.
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 github:Novacon/ohmyjev
146
+ omp plugin install ohmyjev
146
147
  ```
147
148
 
148
- Or from the marketplace this repo already serves: `/marketplace add Novacon/ohmyjev`, then
149
- `/marketplace install ohmyjev@ohmyjev`. Restart the session after installing. Settings use the names in
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 git:github.com/Novacon/ohmyjev
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
- ### `/jev`
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 `/jev settings` to see every setting's current value (the key stays hidden). To change them, 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. `/jev` shows the error count.
360
+ gates let calls through until Jev answers again. `/omj` shows the error count.
339
361
 
340
- **Something got blocked that shouldn't have.** `/jev` shows the reason and Jev's numbers. Raise that gate's threshold
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
- const reason = `${path} is outside the repo and allowPaths`
501
- session.denyByCode(tool, reason)
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
- pi.registerCommand('jev', {
654
- description: "ohmyjev: this session's Jev calls, denies, cost and key source",
655
- async handler(_args, ctx) {
656
- try {
657
- touch(ctx)
658
- ctx.ui.notify(session ? await session.report() : 'ohmyjev: no active session', 'info')
659
- } catch {
660
- try { ctx.ui.notify('ohmyjev: report unavailable', 'error') } catch {}
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
- pi.registerCommand('jev', {
352
- description: "ohmyjev: this session's Jev calls, denies, cost and key source",
353
- handler: async (_args, ctx) => {
354
- try {
355
- const x = await ensureSession(ctx)
356
- ctx.ui.notify(await x.report(), 'info')
357
- } catch {
358
- ctx.ui.notify('ohmyjev report unavailable', 'warning')
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
- const reason = `${path} is outside the repo and allowPaths`
434
- x.denyByCode(tool, reason)
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
@@ -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 { fileURLToPath } from 'node:url'
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, rawAbsolute, sanitizeSid, splitList, statusText,
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
- /** /jev: this session's status and log summary, and where the key comes from (never the key). */
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, plus allowPaths for writes. Spelled the way the host's tools read it (hostPath); another machine never is.
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 /jev ---
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
- /** /jev's body from a session log: calls, errors, cost, p50, denies per tool, the last 5 denies. Torn lines are skipped. */
422
- export function summarize(log: string): string {
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.2.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",