@nocville/npc 0.2.0 → 0.2.2
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/LICENSE +21 -0
- package/README.md +60 -115
- package/dist/cjs/cli.js +51 -0
- package/dist/cjs/core/session-filter.js +152 -0
- package/dist/cjs/index.js +7 -2
- package/dist/cjs/npcs/agent-common/session.js +24 -2
- package/dist/cjs/npcs/codex/watcher.js +16 -1
- package/dist/cli.js +51 -0
- package/dist/core/session-filter.d.ts +59 -0
- package/dist/core/session-filter.js +145 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/npcs/agent-common/session.d.ts +8 -0
- package/dist/npcs/agent-common/session.js +24 -2
- package/dist/npcs/codex/watcher.d.ts +3 -0
- package/dist/npcs/codex/watcher.js +16 -1
- package/package.json +2 -7
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jeff Marshall
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ a hand when it needs you, dozes when idle, and shows what it's doing on its spee
|
|
|
7
7
|
desk sign: `Editing src/GameScene.ts`, `Running pnpm test`, `Searching for 'reconnect'`,
|
|
8
8
|
`Needs your approval`, `Thinking…`. It leaves the office when the session ends.
|
|
9
9
|
|
|
10
|
-
Requires Node.js 20+.
|
|
10
|
+
Requires Node.js 20+. Get an agent key in your Nocville office under **Connect AI coworkers**.
|
|
11
11
|
|
|
12
12
|
## Quick start: AI coworkers
|
|
13
13
|
|
|
@@ -16,28 +16,53 @@ npx @nocville/npc setup
|
|
|
16
16
|
```
|
|
17
17
|
|
|
18
18
|
Paste your key at the prompt (input is hidden, so the key never lands in your shell history).
|
|
19
|
-
For scripts, pass `--api-key=<your
|
|
20
|
-
|
|
19
|
+
For scripts, pass `--api-key=<your key>` or set `NOCVILLE_API_KEY` instead. That's it. The
|
|
20
|
+
command saves your key to `~/.config/nocville/config.json` (readable only by you), then hooks
|
|
21
|
+
up whichever agents you have installed:
|
|
21
22
|
|
|
22
23
|
- **Claude Code**: adds hooks to `~/.claude/settings.json`. New sessions show up right away;
|
|
23
24
|
restart sessions that are already running.
|
|
24
25
|
- **Codex**: adds hooks to `~/.codex/hooks.json`. Codex won't run a new hook until you've
|
|
25
26
|
approved it, so start Codex once and run **`/hooks`** to trust the Nocville hooks.
|
|
27
|
+
- **Gemini CLI**: adds hooks to `~/.gemini/settings.json`, keeping your settings and existing
|
|
28
|
+
hooks. Start a new session after setup.
|
|
29
|
+
- **Cursor**: adds hooks to `~/.cursor/hooks.json`. Start a new chat after setup.
|
|
26
30
|
|
|
27
|
-
|
|
28
|
-
|
|
31
|
+
Your existing hooks are left alone, and Nocville's hooks never answer permission prompts or
|
|
32
|
+
change what your agent does; they only report activity.
|
|
29
33
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
one coworker and deduplicate matching events. No permission decisions or model output are sent.
|
|
33
|
-
|
|
34
|
-
Self-hosting or developing locally? Add `--api-url=http://localhost:3601`. To send sessions to
|
|
35
|
-
a map other than your team's default, add `--map="Trading Floor"`.
|
|
34
|
+
Running your own Nocville server? Add `--api-url=https://your-server`. To send sessions to a
|
|
35
|
+
map other than your team's default, add `--map="Trading Floor"`.
|
|
36
36
|
|
|
37
37
|
Prefer a global install? Run `npm i -g @nocville/npc`, then `nocville-npc setup --api-key=…`.
|
|
38
38
|
When setup runs through `npx`, it copies itself to `~/.config/nocville/runtime/` so the hooks
|
|
39
39
|
keep working after npm clears its cache.
|
|
40
40
|
|
|
41
|
+
### Choose which sessions appear
|
|
42
|
+
|
|
43
|
+
By default every agent session publishes to your office. To keep some out, or to show only
|
|
44
|
+
some, edit `~/.config/nocville/sessions.json` (setup creates an empty one):
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"allow": { "projects": [], "sessions": [] },
|
|
49
|
+
"deny": { "projects": ["client-*", "~/work/secret"], "sessions": ["*private*"] }
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
- **Empty lists** (or no file): every session publishes.
|
|
54
|
+
- **`deny` always wins.** If any `allow` entry is set, **only** matching sessions publish.
|
|
55
|
+
- **`projects`**: a folder name anywhere in the session's working directory (`client-*` also
|
|
56
|
+
covers `~/work/client-acme/api`), or a path containing `/` (`~/work/secret` covers that folder
|
|
57
|
+
and everything inside it).
|
|
58
|
+
- **`sessions`**: a Claude Code session name (set with `/rename`). Unnamed sessions never match.
|
|
59
|
+
- Patterns are case-insensitive; `*` matches anything, `?` one character.
|
|
60
|
+
|
|
61
|
+
The file is read on every update, so edits apply to running sessions without reinstalling: a
|
|
62
|
+
session that becomes filtered out leaves the office on its next update. If the file can't be
|
|
63
|
+
parsed, nothing publishes until it's fixed. `npx @nocville/npc sessions` shows the rules and
|
|
64
|
+
checks the current folder (`--project=<dir>`, `--name=<session name>` to test others).
|
|
65
|
+
|
|
41
66
|
### Per-agent commands
|
|
42
67
|
|
|
43
68
|
| Claude Code | Codex |
|
|
@@ -46,26 +71,8 @@ keep working after npm clears its cache.
|
|
|
46
71
|
| `nocville-npc claude-code uninstall` | `nocville-npc codex uninstall` |
|
|
47
72
|
| | `nocville-npc codex watch` (no-hooks fallback) |
|
|
48
73
|
|
|
49
|
-
`nocville-npc gemini setup` and `nocville-npc
|
|
50
|
-
Gemini
|
|
51
|
-
Its Node process is identified by the Gemini executable path, not an unrelated Node parent.
|
|
52
|
-
|
|
53
|
-
`nocville-npc cursor setup` and `nocville-npc cursor uninstall` manage native Cursor hooks.
|
|
54
|
-
Native commands use 10-second timeouts. Setup installs only sessionStart/sessionEnd,
|
|
55
|
-
beforeSubmitPrompt, postToolUse/postToolUseFailure, afterFileEdit, stop, subagentStop and
|
|
56
|
-
preCompact; it removes older Nocville permission hooks while preserving other hooks.
|
|
57
|
-
Activity comes from completed tools. Native and imported Cursor invocations return exactly
|
|
58
|
-
`{}` with no decision fields. Payload fields identify imported Cursor; `CURSOR_VERSION`
|
|
59
|
-
alone never reclassifies Claude launched inside its terminal. The launcher suppresses child
|
|
60
|
-
stdout for every agent, including debug logs, and emits only the appropriate neutral response.
|
|
61
|
-
Cursor does not watch the editor process or start a watchdog: an abandoned conversation uses
|
|
62
|
-
the one-hour fallback heartbeat interval (unknown after two hours, despawn ten minutes later).
|
|
63
|
-
Uninstall intentionally leaves `{"version":1}` when no other settings remain.
|
|
64
|
-
|
|
65
|
-
Cursor's [documented tool names](https://cursor.com/docs/hooks#configuration) include Grep,
|
|
66
|
-
Delete, Task and MCP tools. Their summaries use the shared redaction/path handling; MCP names
|
|
67
|
-
are normalized to the shared MCP format. Real Cursor Shell/Read/Write verification with both
|
|
68
|
-
Claude-imported-only and native hooks is still required before a public support claim.
|
|
74
|
+
`nocville-npc gemini setup|uninstall` and `nocville-npc cursor setup|uninstall` manage
|
|
75
|
+
Gemini CLI and Cursor on their own. For Cursor, start a new chat after setup.
|
|
69
76
|
|
|
70
77
|
`nocville-npc uninstall` removes every Nocville hook. `nocville-npc init` only saves
|
|
71
78
|
credentials. Every setup command takes the key from `--api-key`, then `NOCVILLE_API_KEY`, then
|
|
@@ -86,58 +93,22 @@ are shortened to their first line, and anything that looks like a secret is reda
|
|
|
86
93
|
env assignments, `--token=`/`--password` flags, bearer headers, URL credentials, and long
|
|
87
94
|
key-like strings. Paths are shown relative to the project.
|
|
88
95
|
|
|
89
|
-
**
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
Re-running setup
|
|
104
|
-
|
|
105
|
-
`nocville-hook.cmd` launcher, which resolves the saved Node path and runs the same bootstrap.
|
|
106
|
-
Windows setup writes plain commands, never `-EncodedCommand` or an execution-policy override.
|
|
107
|
-
Re-running setup replaces the former encoded commands; uninstall still recognizes them.
|
|
108
|
-
|
|
109
|
-
| Agent | Windows command host and verification |
|
|
110
|
-
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
111
|
-
| Claude Code | Explicit per-hook `shell: "powershell"`; invokes `& '<shim>.cmd' claude-code hook`. Claude selects PowerShell 7 or falls back to 5.1. CI exercises both. [Claude hook shell reference](https://code.claude.com/docs/en/hooks#windows-powershell-tool). |
|
|
112
|
-
| Gemini | Its hook runner selects PowerShell 7 or 5.1. The saved `cmd.exe /d /s /c --% ...` preserves cmd quoting, with a terminating newline before Gemini's appended exit check. CI exercises both hosts with that check. [Hook reference](https://geminicli.com/docs/hooks/reference/), [runner](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/hooks/hookRunner.ts), [shell selection](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/utils/shell-utils.ts). |
|
|
113
|
-
| Cursor | Explicit `cmd.exe /d /s /c` launcher; CI executes the saved string under cmd. The [published hook contract](https://cursor.com/docs/hooks) does not specify the Windows outer shell; real Windows Cursor verification remains required. |
|
|
114
|
-
| Codex | Explicit `cmd.exe /d /s /c` launcher; CI executes the saved string under cmd. The [published hook contract](https://learn.chatgpt.com/docs/hooks) does not identify the Windows outer shell; real Windows Codex verification remains required. |
|
|
115
|
-
|
|
116
|
-
PowerShell's [stop-parsing token](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_parsing#the-stop-parsing-token)
|
|
117
|
-
ends at a newline, not a semicolon. Keep Gemini's saved newline: its runner appends PowerShell
|
|
118
|
-
code to the command. No event input is interpolated into shell commands. Windows support remains
|
|
119
|
-
unverified until the Windows CI job and real-agent checks pass; macOS tests cannot establish it.
|
|
120
|
-
**Windows Cursor users:** install native hooks with `nocville-npc cursor setup`; do not rely on
|
|
121
|
-
Cursor importing Claude Code hooks. Claude's saved hook uses PowerShell syntax and an explicit
|
|
122
|
-
`shell` field. If Cursor's import path ignores that field and uses cmd, the imported command can
|
|
123
|
-
fail before the shim runs and produce hook errors. Nocville cannot control Cursor's import path.
|
|
124
|
-
|
|
125
|
-
Windows real-agent owner checklist (required before support claims):
|
|
126
|
-
|
|
127
|
-
- Run the native Windows CI job, including PowerShell 5.1/7 and a spaced `O’Neil` profile path.
|
|
128
|
-
- Verify native Cursor Shell/Read/Write events after `nocville-npc cursor setup`.
|
|
129
|
-
- With Claude hooks also installed, check whether Cursor imports them and honors their `shell`
|
|
130
|
-
field. Record agent versions and any duplicate/error events; verify native Cursor hooks work
|
|
131
|
-
independently. Do not count an imported Claude hook as proof of native Cursor support.
|
|
132
|
-
- Verify the actual Claude, Gemini and Codex hosts using their installed saved commands.
|
|
133
|
-
|
|
134
|
-
Claude's single-quoted path doubles straight and curly apostrophes according to
|
|
135
|
-
[PowerShell quoting rules](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_quoting_rules#including-quote-characters-in-a-string).
|
|
136
|
-
|
|
137
|
-
Both launchers discard child stdout and preserve stderr; all SDK logger levels use stderr.
|
|
138
|
-
If Node is unavailable, the shim drains stdin and returns neutral `{}` for Cursor/Gemini
|
|
139
|
-
and Claude hooks (which may have been imported by Cursor); Codex stays silent. Session records are local, including terminal
|
|
140
|
-
records that prevent late events from reviving an ended NPC; `cleanup` removes old records.
|
|
96
|
+
**Overhead:** hooks return immediately and send updates from a background process, so your
|
|
97
|
+
agent never waits on the network. Rapid bursts of activity are combined into one update.
|
|
98
|
+
|
|
99
|
+
If an agent is killed, its coworker leaves the office within about 15 seconds. If your machine
|
|
100
|
+
sleeps or goes offline, the coworker greys out after a few minutes and leaves after about
|
|
101
|
+
thirteen. Claude subagent activity appears below the parent's current activity.
|
|
102
|
+
|
|
103
|
+
### Windows
|
|
104
|
+
|
|
105
|
+
Setup works the same way on Windows; it writes a small `nocville-hook.cmd` launcher to
|
|
106
|
+
`~/.config/nocville/bin/` and points each agent's hooks at it. **Cursor users on Windows:**
|
|
107
|
+
install Cursor's own hooks with `nocville-npc cursor setup` rather than relying on Cursor
|
|
108
|
+
importing your Claude Code hooks.
|
|
109
|
+
|
|
110
|
+
Re-running `setup` (for example after upgrading with `npx @nocville/npc@latest setup`) keeps
|
|
111
|
+
your hooks in place and switches them to the new version; running sessions are not interrupted.
|
|
141
112
|
|
|
142
113
|
### How the Codex integration works
|
|
143
114
|
|
|
@@ -150,9 +121,8 @@ hooks, makes a one-time `hooks.json.nocville-backup`, and is safe to run again.
|
|
|
150
121
|
reads the rollout logs Codex writes to `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`. It
|
|
151
122
|
shows one NPC per interactive session (subagent and reviewer threads are skipped; add
|
|
152
123
|
`--include-exec` to include `codex exec` runs). A session leaves the office after 30 minutes
|
|
153
|
-
without log activity (`--idle-timeout=<minutes>`) or when you press Ctrl-C.
|
|
154
|
-
|
|
155
|
-
leaves that session to them. `doctor` warns when both modes are running.
|
|
124
|
+
without log activity (`--idle-timeout=<minutes>`) or when you press Ctrl-C. Sessions that
|
|
125
|
+
hooks already report are left to the hooks.
|
|
156
126
|
|
|
157
127
|
## Monitoring NPCs
|
|
158
128
|
|
|
@@ -199,7 +169,7 @@ import type { CollectedMetrics } from '@nocville/npc';
|
|
|
199
169
|
|
|
200
170
|
await new SystemMonitorNPC({
|
|
201
171
|
apiUrl: 'https://nocville.com',
|
|
202
|
-
apiKey: '
|
|
172
|
+
apiKey: 'nvk_…',
|
|
203
173
|
serviceId: 'web-1',
|
|
204
174
|
}).start();
|
|
205
175
|
|
|
@@ -213,32 +183,7 @@ class MyNPC extends PollingNPC {
|
|
|
213
183
|
}
|
|
214
184
|
```
|
|
215
185
|
|
|
216
|
-
##
|
|
217
|
-
|
|
218
|
-
```bash
|
|
219
|
-
pnpm install
|
|
220
|
-
pnpm --filter @nocville/npc build
|
|
221
|
-
pnpm --filter @nocville/npc test
|
|
222
|
-
node libs/nocville-npc-library/dist/cli.js --help
|
|
223
|
-
SMOKE_STUB=1 pnpm --filter @nocville/npc smoke # every payload shape, no server needed
|
|
224
|
-
```
|
|
225
|
-
|
|
226
|
-
**Watch an AI-coworker NPC without running an agent.** `simulate` plays a scripted session
|
|
227
|
-
(start → prompt → Read → Edit → `pnpm test` → approval wait → idle → end) through the real
|
|
228
|
-
built hook handlers. It works in a throwaway HOME, so your `~/.claude` and `~/.codex` are
|
|
229
|
-
untouched, and the NPC appears as "Claude · demo" (or "Codex · demo"):
|
|
186
|
+
## Help
|
|
230
187
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
pnpm --filter @nocville/npc simulate codex --api-key=<key> --project=gridbreak --fast
|
|
234
|
-
# --hold stay idle after Stop until Ctrl-C, then end the session
|
|
235
|
-
# --fast 5x shorter pauses (CI)
|
|
236
|
-
```
|
|
237
|
-
|
|
238
|
-
**Real hooks against your local checkout.** This writes real hooks into `~/.claude` and
|
|
239
|
-
`~/.codex` that run your working copy's `dist/cli.js`:
|
|
240
|
-
|
|
241
|
-
```bash
|
|
242
|
-
node libs/nocville-npc-library/dist/cli.js setup --api-key=<key> --api-url=http://localhost:3601
|
|
243
|
-
node libs/nocville-npc-library/dist/cli.js uninstall # remove them again
|
|
244
|
-
```
|
|
188
|
+
`nocville-npc --help` lists every command and flag, and `nocville-npc doctor` checks that your
|
|
189
|
+
key and connection work. More at [nocville.com](https://nocville.com).
|
package/dist/cjs/cli.js
CHANGED
|
@@ -39,6 +39,7 @@ const node_os_1 = require("node:os");
|
|
|
39
39
|
const node_path_1 = require("node:path");
|
|
40
40
|
const config_js_1 = require("./core/config.js");
|
|
41
41
|
const logger_js_1 = require("./core/logger.js");
|
|
42
|
+
const session_filter_js_1 = require("./core/session-filter.js");
|
|
42
43
|
// NPC modules are imported lazily per command: `claude-code hook` / `codex hook`
|
|
43
44
|
// run on every tool call, so they must not pay for loading every NPC (and pngjs).
|
|
44
45
|
const USAGE = `
|
|
@@ -56,6 +57,10 @@ Config:
|
|
|
56
57
|
Save credentials (prompts for the key if omitted) to ~/.config/nocville/config.json (0600)
|
|
57
58
|
so no other command needs --api-key
|
|
58
59
|
doctor Check API connectivity and a test status push
|
|
60
|
+
sessions [--project=<dir>] [--name=<session name>]
|
|
61
|
+
Show which AI sessions publish to your office
|
|
62
|
+
(allow/deny lists in ~/.config/nocville/sessions.json)
|
|
63
|
+
and test a folder (default: current) or session name
|
|
59
64
|
|
|
60
65
|
AI coding agents (one NPC per session, named "Claude · <project>"):
|
|
61
66
|
claude-code setup Install hooks into ~/.claude/settings.json
|
|
@@ -195,6 +200,8 @@ async function main() {
|
|
|
195
200
|
flags: parsed.flags,
|
|
196
201
|
args: parsed.args,
|
|
197
202
|
});
|
|
203
|
+
if (parsed.subcommand === 'setup' && !parsed.flags['uninstall'])
|
|
204
|
+
announceSessionFilter();
|
|
198
205
|
return;
|
|
199
206
|
}
|
|
200
207
|
if (parsed.command === 'codex') {
|
|
@@ -205,6 +212,8 @@ async function main() {
|
|
|
205
212
|
flags: parsed.flags,
|
|
206
213
|
args: parsed.args,
|
|
207
214
|
});
|
|
215
|
+
if (parsed.subcommand === 'setup' && !parsed.flags['uninstall'])
|
|
216
|
+
announceSessionFilter();
|
|
208
217
|
return;
|
|
209
218
|
}
|
|
210
219
|
if (parsed.command === 'gemini') {
|
|
@@ -215,6 +224,8 @@ async function main() {
|
|
|
215
224
|
flags: parsed.flags,
|
|
216
225
|
args: parsed.args,
|
|
217
226
|
});
|
|
227
|
+
if (parsed.subcommand === 'setup' && !parsed.flags['uninstall'])
|
|
228
|
+
announceSessionFilter();
|
|
218
229
|
return;
|
|
219
230
|
}
|
|
220
231
|
if (parsed.command === 'cursor') {
|
|
@@ -225,6 +236,8 @@ async function main() {
|
|
|
225
236
|
flags: parsed.flags,
|
|
226
237
|
args: parsed.args,
|
|
227
238
|
});
|
|
239
|
+
if (parsed.subcommand === 'setup' && !parsed.flags['uninstall'])
|
|
240
|
+
announceSessionFilter();
|
|
228
241
|
return;
|
|
229
242
|
}
|
|
230
243
|
if (parsed.command === 'init') {
|
|
@@ -233,6 +246,12 @@ async function main() {
|
|
|
233
246
|
}
|
|
234
247
|
if (parsed.command === 'setup') {
|
|
235
248
|
await runSetupAll(parsed.flags);
|
|
249
|
+
if (!process.exitCode)
|
|
250
|
+
announceSessionFilter();
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (parsed.command === 'sessions') {
|
|
254
|
+
runSessionsCommand(parsed.flags);
|
|
236
255
|
return;
|
|
237
256
|
}
|
|
238
257
|
if (parsed.command === 'uninstall') {
|
|
@@ -478,6 +497,38 @@ async function resolveSetupKey(flags) {
|
|
|
478
497
|
return resolved.source === 'saved' ? undefined : resolved.key;
|
|
479
498
|
}
|
|
480
499
|
/** `setup`: save credentials, then install Claude Code + Codex hooks where present. */
|
|
500
|
+
/** After setup: make sure sessions.json exists and tell the user how to filter sessions. */
|
|
501
|
+
function announceSessionFilter() {
|
|
502
|
+
const { path, created } = (0, session_filter_js_1.ensureSessionFilterTemplate)();
|
|
503
|
+
console.log(`\n Choose which sessions appear in your office: ${created ? 'created' : 'edit'} ${tildify(path)}\n` +
|
|
504
|
+
' (allow/deny by project folder or session name; empty = all sessions publish).\n' +
|
|
505
|
+
' Check the result with: npx @nocville/npc sessions');
|
|
506
|
+
}
|
|
507
|
+
function tildify(p) {
|
|
508
|
+
const home = (0, node_os_1.homedir)();
|
|
509
|
+
return p.startsWith(home) ? '~' + p.slice(home.length) : p;
|
|
510
|
+
}
|
|
511
|
+
/** `nocville-npc sessions`: show the allow/deny rules and test a folder / session name. */
|
|
512
|
+
function runSessionsCommand(flags) {
|
|
513
|
+
const { path, created } = (0, session_filter_js_1.ensureSessionFilterTemplate)();
|
|
514
|
+
const filter = (0, session_filter_js_1.loadSessionFilter)(path);
|
|
515
|
+
console.log(`Session filter: ${tildify(path)}${created ? ' (created an empty template)' : ''}`);
|
|
516
|
+
if (filter.error) {
|
|
517
|
+
console.log(` ✗ ${filter.error}\n Nothing publishes until the file is fixed.`);
|
|
518
|
+
process.exitCode = 1;
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
const show = (label, list) => console.log(` ${label.padEnd(16)} ${list.length ? list.map((p) => JSON.stringify(p)).join(', ') : '(none)'}`);
|
|
522
|
+
show('allow projects:', filter.allow.projects);
|
|
523
|
+
show('allow sessions:', filter.allow.sessions);
|
|
524
|
+
show('deny projects:', filter.deny.projects);
|
|
525
|
+
show('deny sessions:', filter.deny.sessions);
|
|
526
|
+
const projectDir = (0, config_js_1.stringFlag)(flags, 'project') ?? process.cwd();
|
|
527
|
+
const name = (0, config_js_1.stringFlag)(flags, 'name');
|
|
528
|
+
const verdict = (0, session_filter_js_1.sessionPublishDecision)(filter, { projectDir, name });
|
|
529
|
+
const subject = `Sessions in ${tildify(projectDir)}${name ? ` named "${name}"` : ''}`;
|
|
530
|
+
console.log(`\n${verdict.publish ? '✓' : '✗'} ${subject} ${verdict.publish ? 'publish' : 'do NOT publish'} — ${verdict.reason}.`);
|
|
531
|
+
}
|
|
481
532
|
async function runSetupAll(flags) {
|
|
482
533
|
const { setupHooks } = await Promise.resolve().then(() => __importStar(require('./npcs/claude-code/index.js')));
|
|
483
534
|
const { setupCodexHooks } = await Promise.resolve().then(() => __importStar(require('./npcs/codex/index.js')));
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SESSION_FILTER_TEMPLATE = void 0;
|
|
4
|
+
exports.sessionFilterPath = sessionFilterPath;
|
|
5
|
+
exports.loadSessionFilter = loadSessionFilter;
|
|
6
|
+
exports.ensureSessionFilterTemplate = ensureSessionFilterTemplate;
|
|
7
|
+
exports.sessionPublishDecision = sessionPublishDecision;
|
|
8
|
+
const node_fs_1 = require("node:fs");
|
|
9
|
+
const node_os_1 = require("node:os");
|
|
10
|
+
const node_path_1 = require("node:path");
|
|
11
|
+
const user_config_js_1 = require("./user-config.js");
|
|
12
|
+
const EMPTY = { projects: [], sessions: [] };
|
|
13
|
+
exports.SESSION_FILTER_TEMPLATE = {
|
|
14
|
+
_help: [
|
|
15
|
+
'Choose which AI agent sessions (Claude Code, Codex, Gemini CLI, Cursor) appear in your Nocville office.',
|
|
16
|
+
'Leave every list empty to publish all sessions (the default).',
|
|
17
|
+
'deny always wins. If any allow entry is set, ONLY sessions matching an allow entry publish.',
|
|
18
|
+
'projects: a folder name anywhere in the session working directory, e.g. "client-*",',
|
|
19
|
+
' or a path containing / such as "~/work/secret" (that folder and everything inside it).',
|
|
20
|
+
'sessions: a Claude Code session name, e.g. "*private*".',
|
|
21
|
+
'Patterns are case-insensitive; * matches anything, ? one character.',
|
|
22
|
+
'Changes apply to running sessions on their next update. Check with: npx @nocville/npc sessions',
|
|
23
|
+
],
|
|
24
|
+
allow: { projects: [], sessions: [] },
|
|
25
|
+
deny: { projects: [], sessions: [] },
|
|
26
|
+
};
|
|
27
|
+
/** `NOCVILLE_SESSIONS_CONFIG` overrides; otherwise `sessions.json` beside config.json. */
|
|
28
|
+
function sessionFilterPath() {
|
|
29
|
+
return (process.env['NOCVILLE_SESSIONS_CONFIG'] || (0, node_path_1.join)((0, node_path_1.dirname)((0, user_config_js_1.userConfigPath)()), 'sessions.json'));
|
|
30
|
+
}
|
|
31
|
+
function rules(raw, where) {
|
|
32
|
+
if (raw === undefined || raw === null)
|
|
33
|
+
return EMPTY;
|
|
34
|
+
if (typeof raw !== 'object' || Array.isArray(raw))
|
|
35
|
+
throw new Error(`"${where}" must be an object with "projects" and "sessions" lists`);
|
|
36
|
+
const list = (key) => {
|
|
37
|
+
const value = raw[key];
|
|
38
|
+
if (value === undefined || value === null)
|
|
39
|
+
return [];
|
|
40
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== 'string'))
|
|
41
|
+
throw new Error(`"${where}.${key}" must be a list of strings`);
|
|
42
|
+
return value.map((v) => v.trim()).filter(Boolean);
|
|
43
|
+
};
|
|
44
|
+
return { projects: list('projects'), sessions: list('sessions') };
|
|
45
|
+
}
|
|
46
|
+
/** Never throws. A missing file means "publish everything". */
|
|
47
|
+
function loadSessionFilter(path = sessionFilterPath()) {
|
|
48
|
+
let text;
|
|
49
|
+
try {
|
|
50
|
+
text = (0, node_fs_1.readFileSync)(path, 'utf-8');
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
if (err.code === 'ENOENT')
|
|
54
|
+
return { allow: EMPTY, deny: EMPTY };
|
|
55
|
+
return { allow: EMPTY, deny: EMPTY, error: `can't read ${path}: ${err.message}` };
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const raw = JSON.parse(text);
|
|
59
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
60
|
+
throw new Error('expected a JSON object with "allow" and "deny"');
|
|
61
|
+
const obj = raw;
|
|
62
|
+
return { allow: rules(obj['allow'], 'allow'), deny: rules(obj['deny'], 'deny') };
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
return { allow: EMPTY, deny: EMPTY, error: `${path}: ${err.message}` };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Write the commented template if no file exists; returns the path. */
|
|
69
|
+
function ensureSessionFilterTemplate(path = sessionFilterPath()) {
|
|
70
|
+
if ((0, node_fs_1.existsSync)(path))
|
|
71
|
+
return { path, created: false };
|
|
72
|
+
try {
|
|
73
|
+
const dir = (0, node_path_1.dirname)(path);
|
|
74
|
+
if (!(0, node_fs_1.existsSync)(dir))
|
|
75
|
+
(0, node_fs_1.mkdirSync)(dir, { recursive: true, mode: 0o700 });
|
|
76
|
+
(0, node_fs_1.writeFileSync)(path, JSON.stringify(exports.SESSION_FILTER_TEMPLATE, null, 2) + '\n', {
|
|
77
|
+
encoding: 'utf-8',
|
|
78
|
+
mode: 0o600,
|
|
79
|
+
flag: 'wx',
|
|
80
|
+
});
|
|
81
|
+
return { path, created: true };
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return { path, created: false };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const normalizePath = (p) => p.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
88
|
+
function globRegExp(pattern, pathMode) {
|
|
89
|
+
let src = '';
|
|
90
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
91
|
+
const c = pattern[i];
|
|
92
|
+
if (c === '*') {
|
|
93
|
+
if (pattern[i + 1] === '*') {
|
|
94
|
+
src += '.*';
|
|
95
|
+
i++;
|
|
96
|
+
}
|
|
97
|
+
else
|
|
98
|
+
src += pathMode ? '[^/]*' : '.*';
|
|
99
|
+
}
|
|
100
|
+
else if (c === '?')
|
|
101
|
+
src += pathMode ? '[^/]' : '.';
|
|
102
|
+
else
|
|
103
|
+
src += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
104
|
+
}
|
|
105
|
+
return new RegExp(`^${src}$`, 'i');
|
|
106
|
+
}
|
|
107
|
+
function projectMatches(pattern, projectDir) {
|
|
108
|
+
const dir = normalizePath(projectDir);
|
|
109
|
+
if (!dir)
|
|
110
|
+
return false;
|
|
111
|
+
const isPath = pattern.includes('/') || pattern.includes('\\') || pattern.startsWith('~');
|
|
112
|
+
if (!isPath) {
|
|
113
|
+
const re = globRegExp(pattern, false);
|
|
114
|
+
return dir.split('/').some((segment) => segment && re.test(segment));
|
|
115
|
+
}
|
|
116
|
+
// A trailing `/*` or `/**` means "this folder and everything in it": subfolders already
|
|
117
|
+
// match through their ancestors, so drop it and the folder itself matches too.
|
|
118
|
+
const expanded = normalizePath((pattern.startsWith('~') ? (0, node_os_1.homedir)() + pattern.slice(1) : pattern).replace(/(\/\*{1,2})+$/, ''));
|
|
119
|
+
const re = globRegExp(expanded, true);
|
|
120
|
+
// The directory itself or any ancestor: a rule for a folder covers its subfolders.
|
|
121
|
+
for (let p = dir; p; p = p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : '') {
|
|
122
|
+
if (re.test(p))
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
function firstMatch(set, who) {
|
|
128
|
+
for (const p of set.projects)
|
|
129
|
+
if (projectMatches(p, who.projectDir))
|
|
130
|
+
return `project "${p}"`;
|
|
131
|
+
if (who.name)
|
|
132
|
+
for (const p of set.sessions)
|
|
133
|
+
if (globRegExp(p, false).test(who.name.trim()))
|
|
134
|
+
return `session "${p}"`;
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
/** Decide whether a session may publish. Pure apart from `homedir()` for `~` patterns. */
|
|
138
|
+
function sessionPublishDecision(filter, who) {
|
|
139
|
+
if (filter.error)
|
|
140
|
+
return { publish: false, reason: `sessions.json is invalid (${filter.error})` };
|
|
141
|
+
const denied = firstMatch(filter.deny, who);
|
|
142
|
+
if (denied)
|
|
143
|
+
return { publish: false, reason: `denied by ${denied}` };
|
|
144
|
+
const hasAllow = filter.allow.projects.length > 0 || filter.allow.sessions.length > 0;
|
|
145
|
+
if (!hasAllow)
|
|
146
|
+
return { publish: true, reason: 'no allowlist (all sessions publish)' };
|
|
147
|
+
const allowed = firstMatch(filter.allow, who);
|
|
148
|
+
return allowed
|
|
149
|
+
? { publish: true, reason: `allowed by ${allowed}` }
|
|
150
|
+
: { publish: false, reason: 'not on the allowlist' };
|
|
151
|
+
}
|
|
152
|
+
//# sourceMappingURL=session-filter.js.map
|
package/dist/cjs/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.
|
|
4
|
-
exports.normalizeCursorEvent = exports.isCursorInvocation = exports.CURSOR_HOOK_EVENTS = exports.installCursorHooksInto = exports.uninstallCursorHooks = exports.setupCursorHooks = exports.handleCursorHookEvent = exports.cursorServiceId = exports.CURSOR_ADAPTER = exports.CURSOR_DEFAULTS = exports.GEMINI_HOOK_EVENTS = void 0;
|
|
3
|
+
exports.geminiServiceId = exports.geminiTransition = exports.GEMINI_ADAPTER = exports.GEMINI_DEFAULTS = exports.WebPageWatcherNPC = exports.GitHubWatcherNPC = exports.ReceptionistNPC = exports.LogWatcherNPC = exports.ProcessWatcherNPC = exports.InternetConnectivityNPC = exports.ensureSessionFilterTemplate = exports.sessionPublishDecision = exports.sessionFilterPath = exports.loadSessionFilter = exports.userConfigPath = exports.saveUserConfig = exports.loadUserConfig = exports.redactSecrets = exports.shortPath = exports.shortCommand = exports.describeToolUse = exports.CodexWatcher = exports.parseRolloutLine = exports.uninstallCodexHooks = exports.setupCodexHooks = exports.codexTransition = exports.handleCodexHookEvent = exports.handleCodexCommand = exports.claudeTransition = exports.processHookEvent = exports.uninstallHooks = exports.setupHooks = exports.handleHookEvent = exports.handleClaudeCodeCommand = exports.ClaudeCodeNPC = exports.SystemMonitorNPC = exports.pushHistory = exports.statusColor = exports.truncate = exports.worstStatus = exports.evaluateThreshold = exports.numberFlag = exports.boolFlag = exports.stringFlag = exports.resolveConfig = exports.parseArgs = exports.Logger = exports.EventNPC = exports.PollingNPC = exports.BaseNPC = void 0;
|
|
4
|
+
exports.normalizeCursorEvent = exports.isCursorInvocation = exports.CURSOR_HOOK_EVENTS = exports.installCursorHooksInto = exports.uninstallCursorHooks = exports.setupCursorHooks = exports.handleCursorHookEvent = exports.cursorServiceId = exports.CURSOR_ADAPTER = exports.CURSOR_DEFAULTS = exports.GEMINI_HOOK_EVENTS = exports.installGeminiHooksInto = exports.uninstallGeminiHooks = exports.setupGeminiHooks = exports.handleGeminiHookEvent = void 0;
|
|
5
5
|
// Core
|
|
6
6
|
var base_npc_js_1 = require("./core/base-npc.js");
|
|
7
7
|
Object.defineProperty(exports, "BaseNPC", { enumerable: true, get: function () { return base_npc_js_1.BaseNPC; } });
|
|
@@ -53,6 +53,11 @@ var user_config_js_1 = require("./core/user-config.js");
|
|
|
53
53
|
Object.defineProperty(exports, "loadUserConfig", { enumerable: true, get: function () { return user_config_js_1.loadUserConfig; } });
|
|
54
54
|
Object.defineProperty(exports, "saveUserConfig", { enumerable: true, get: function () { return user_config_js_1.saveUserConfig; } });
|
|
55
55
|
Object.defineProperty(exports, "userConfigPath", { enumerable: true, get: function () { return user_config_js_1.userConfigPath; } });
|
|
56
|
+
var session_filter_js_1 = require("./core/session-filter.js");
|
|
57
|
+
Object.defineProperty(exports, "loadSessionFilter", { enumerable: true, get: function () { return session_filter_js_1.loadSessionFilter; } });
|
|
58
|
+
Object.defineProperty(exports, "sessionFilterPath", { enumerable: true, get: function () { return session_filter_js_1.sessionFilterPath; } });
|
|
59
|
+
Object.defineProperty(exports, "sessionPublishDecision", { enumerable: true, get: function () { return session_filter_js_1.sessionPublishDecision; } });
|
|
60
|
+
Object.defineProperty(exports, "ensureSessionFilterTemplate", { enumerable: true, get: function () { return session_filter_js_1.ensureSessionFilterTemplate; } });
|
|
56
61
|
var index_js_4 = require("./npcs/internet-connectivity/index.js");
|
|
57
62
|
Object.defineProperty(exports, "InternetConnectivityNPC", { enumerable: true, get: function () { return index_js_4.InternetConnectivityNPC; } });
|
|
58
63
|
var index_js_5 = require("./npcs/process-watcher/index.js");
|
|
@@ -24,6 +24,7 @@ const node_child_process_1 = require("node:child_process");
|
|
|
24
24
|
const node_fs_1 = require("node:fs");
|
|
25
25
|
const node_path_1 = require("node:path");
|
|
26
26
|
const node_1 = require("@nocville/node");
|
|
27
|
+
const session_filter_js_1 = require("../../core/session-filter.js");
|
|
27
28
|
const text_js_1 = require("../../core/text.js");
|
|
28
29
|
const lock_js_1 = require("./lock.js");
|
|
29
30
|
/** Behavior hints + subStatus for a phase (shared by every agent NPC). */
|
|
@@ -285,6 +286,25 @@ async function flushSession(adapter, config, sessionId, deps = {}) {
|
|
|
285
286
|
if (!state || !config.apiKey || state.offlineDeliveryStopped)
|
|
286
287
|
return false;
|
|
287
288
|
const rev = state.rev ?? 0;
|
|
289
|
+
const verdict = (0, session_filter_js_1.sessionPublishDecision)(deps.sessionFilter ?? (0, session_filter_js_1.loadSessionFilter)(), {
|
|
290
|
+
projectDir: state.projectDir,
|
|
291
|
+
name: state.name,
|
|
292
|
+
});
|
|
293
|
+
// Filtered out (sessions.json). Never shown: consume the revision so nothing retries.
|
|
294
|
+
// Already shown (e.g. renamed into the denylist): despawn it once, then go quiet.
|
|
295
|
+
// Checked on every push, so later edits to the file take effect without reinstalling.
|
|
296
|
+
if (!verdict.publish && !state.published) {
|
|
297
|
+
await (0, lock_js_1.withStateLock)(path, () => {
|
|
298
|
+
const fresh = readSessionState(adapter.stateDir, sessionId);
|
|
299
|
+
if (!fresh)
|
|
300
|
+
return;
|
|
301
|
+
fresh.pushedRev = Math.max(fresh.pushedRev ?? 0, fresh.rev ?? 0);
|
|
302
|
+
fresh.flushAt = undefined;
|
|
303
|
+
writeSessionState(adapter.stateDir, fresh);
|
|
304
|
+
}, deps.lockWaitMs);
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
const despawnFiltered = !verdict.publish;
|
|
288
308
|
if (!deps.force && rev <= (state.pushedRev ?? 0)) {
|
|
289
309
|
await (0, lock_js_1.withStateLock)(path, () => {
|
|
290
310
|
const fresh = readSessionState(adapter.stateDir, sessionId);
|
|
@@ -299,7 +319,8 @@ async function flushSession(adapter, config, sessionId, deps = {}) {
|
|
|
299
319
|
((sid, payload) => defaultPush(config, sid, payload, deps.timeoutMs ?? 4000));
|
|
300
320
|
let ok = false;
|
|
301
321
|
try {
|
|
302
|
-
const
|
|
322
|
+
const shown = despawnFiltered ? { ...state, offline: true } : state;
|
|
323
|
+
const result = await push(state.serviceId, buildAgentPayload(adapter, shown, config));
|
|
303
324
|
ok = result.success;
|
|
304
325
|
deps.onResult?.(result);
|
|
305
326
|
}
|
|
@@ -312,8 +333,9 @@ async function flushSession(adapter, config, sessionId, deps = {}) {
|
|
|
312
333
|
return;
|
|
313
334
|
// Retain terminal state until cleanup so late hooks/watchers cannot revive it.
|
|
314
335
|
if (ok) {
|
|
315
|
-
fresh.pushedRev = Math.max(fresh.pushedRev ?? 0, rev);
|
|
336
|
+
fresh.pushedRev = Math.max(fresh.pushedRev ?? 0, despawnFiltered ? (fresh.rev ?? 0) : rev);
|
|
316
337
|
fresh.lastPushAt = Date.now();
|
|
338
|
+
fresh.published = !despawnFiltered && !state.offline;
|
|
317
339
|
}
|
|
318
340
|
if (!fresh.flushAt || fresh.flushAt <= Date.now())
|
|
319
341
|
fresh.flushAt = undefined;
|
|
@@ -6,6 +6,7 @@ const node_fs_1 = require("node:fs");
|
|
|
6
6
|
const node_path_1 = require("node:path");
|
|
7
7
|
const node_1 = require("@nocville/node");
|
|
8
8
|
const logger_js_1 = require("../../core/logger.js");
|
|
9
|
+
const session_filter_js_1 = require("../../core/session-filter.js");
|
|
9
10
|
const session_js_1 = require("../agent-common/session.js");
|
|
10
11
|
const defaults_js_1 = require("./defaults.js");
|
|
11
12
|
const hook_handler_js_1 = require("./hook-handler.js");
|
|
@@ -296,6 +297,20 @@ class CodexWatcher {
|
|
|
296
297
|
s.pushedRev = rev;
|
|
297
298
|
return;
|
|
298
299
|
}
|
|
300
|
+
const verdict = (0, session_filter_js_1.sessionPublishDecision)(this.opts.sessionFilter ?? (0, session_filter_js_1.loadSessionFilter)(), {
|
|
301
|
+
projectDir: s.state.projectDir,
|
|
302
|
+
name: s.state.name,
|
|
303
|
+
});
|
|
304
|
+
if (!verdict.publish) {
|
|
305
|
+
// Filtered out by sessions.json: never show it, and remove it if it was showing.
|
|
306
|
+
s.pushedRev = rev;
|
|
307
|
+
if (s.spawned) {
|
|
308
|
+
s.spawned = false;
|
|
309
|
+
const offline = { ...s.state, offline: true };
|
|
310
|
+
await this.send(s.state.serviceId, (0, session_js_1.buildAgentPayload)(hook_handler_js_1.CODEX_ADAPTER, offline, this.config));
|
|
311
|
+
}
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
299
314
|
const now = this.now();
|
|
300
315
|
if (s.spawned && now - s.lastPushAt < this.minPushGapMs) {
|
|
301
316
|
// Coalesce; the next tick after the gap pushes the latest state.
|
|
@@ -307,7 +322,7 @@ class CodexWatcher {
|
|
|
307
322
|
await this.send(s.state.serviceId, (0, session_js_1.buildAgentPayload)(hook_handler_js_1.CODEX_ADAPTER, s.state, this.config));
|
|
308
323
|
}
|
|
309
324
|
async despawn(s) {
|
|
310
|
-
if (this.hooksOwn(s.state.sessionId))
|
|
325
|
+
if (this.hooksOwn(s.state.sessionId) || !s.spawned)
|
|
311
326
|
return;
|
|
312
327
|
const offline = { ...s.state, offline: true };
|
|
313
328
|
await this.send(s.state.serviceId, (0, session_js_1.buildAgentPayload)(hook_handler_js_1.CODEX_ADAPTER, offline, this.config));
|
package/dist/cli.js
CHANGED
|
@@ -4,6 +4,7 @@ import { homedir } from 'node:os';
|
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { parseArgs, resolveConfig, stringFlag, numberFlag, boolFlag } from './core/config.js';
|
|
6
6
|
import { Logger } from './core/logger.js';
|
|
7
|
+
import { ensureSessionFilterTemplate, loadSessionFilter, sessionPublishDecision, } from './core/session-filter.js';
|
|
7
8
|
// NPC modules are imported lazily per command: `claude-code hook` / `codex hook`
|
|
8
9
|
// run on every tool call, so they must not pay for loading every NPC (and pngjs).
|
|
9
10
|
const USAGE = `
|
|
@@ -21,6 +22,10 @@ Config:
|
|
|
21
22
|
Save credentials (prompts for the key if omitted) to ~/.config/nocville/config.json (0600)
|
|
22
23
|
so no other command needs --api-key
|
|
23
24
|
doctor Check API connectivity and a test status push
|
|
25
|
+
sessions [--project=<dir>] [--name=<session name>]
|
|
26
|
+
Show which AI sessions publish to your office
|
|
27
|
+
(allow/deny lists in ~/.config/nocville/sessions.json)
|
|
28
|
+
and test a folder (default: current) or session name
|
|
24
29
|
|
|
25
30
|
AI coding agents (one NPC per session, named "Claude · <project>"):
|
|
26
31
|
claude-code setup Install hooks into ~/.claude/settings.json
|
|
@@ -160,6 +165,8 @@ async function main() {
|
|
|
160
165
|
flags: parsed.flags,
|
|
161
166
|
args: parsed.args,
|
|
162
167
|
});
|
|
168
|
+
if (parsed.subcommand === 'setup' && !parsed.flags['uninstall'])
|
|
169
|
+
announceSessionFilter();
|
|
163
170
|
return;
|
|
164
171
|
}
|
|
165
172
|
if (parsed.command === 'codex') {
|
|
@@ -170,6 +177,8 @@ async function main() {
|
|
|
170
177
|
flags: parsed.flags,
|
|
171
178
|
args: parsed.args,
|
|
172
179
|
});
|
|
180
|
+
if (parsed.subcommand === 'setup' && !parsed.flags['uninstall'])
|
|
181
|
+
announceSessionFilter();
|
|
173
182
|
return;
|
|
174
183
|
}
|
|
175
184
|
if (parsed.command === 'gemini') {
|
|
@@ -180,6 +189,8 @@ async function main() {
|
|
|
180
189
|
flags: parsed.flags,
|
|
181
190
|
args: parsed.args,
|
|
182
191
|
});
|
|
192
|
+
if (parsed.subcommand === 'setup' && !parsed.flags['uninstall'])
|
|
193
|
+
announceSessionFilter();
|
|
183
194
|
return;
|
|
184
195
|
}
|
|
185
196
|
if (parsed.command === 'cursor') {
|
|
@@ -190,6 +201,8 @@ async function main() {
|
|
|
190
201
|
flags: parsed.flags,
|
|
191
202
|
args: parsed.args,
|
|
192
203
|
});
|
|
204
|
+
if (parsed.subcommand === 'setup' && !parsed.flags['uninstall'])
|
|
205
|
+
announceSessionFilter();
|
|
193
206
|
return;
|
|
194
207
|
}
|
|
195
208
|
if (parsed.command === 'init') {
|
|
@@ -198,6 +211,12 @@ async function main() {
|
|
|
198
211
|
}
|
|
199
212
|
if (parsed.command === 'setup') {
|
|
200
213
|
await runSetupAll(parsed.flags);
|
|
214
|
+
if (!process.exitCode)
|
|
215
|
+
announceSessionFilter();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (parsed.command === 'sessions') {
|
|
219
|
+
runSessionsCommand(parsed.flags);
|
|
201
220
|
return;
|
|
202
221
|
}
|
|
203
222
|
if (parsed.command === 'uninstall') {
|
|
@@ -443,6 +462,38 @@ async function resolveSetupKey(flags) {
|
|
|
443
462
|
return resolved.source === 'saved' ? undefined : resolved.key;
|
|
444
463
|
}
|
|
445
464
|
/** `setup`: save credentials, then install Claude Code + Codex hooks where present. */
|
|
465
|
+
/** After setup: make sure sessions.json exists and tell the user how to filter sessions. */
|
|
466
|
+
function announceSessionFilter() {
|
|
467
|
+
const { path, created } = ensureSessionFilterTemplate();
|
|
468
|
+
console.log(`\n Choose which sessions appear in your office: ${created ? 'created' : 'edit'} ${tildify(path)}\n` +
|
|
469
|
+
' (allow/deny by project folder or session name; empty = all sessions publish).\n' +
|
|
470
|
+
' Check the result with: npx @nocville/npc sessions');
|
|
471
|
+
}
|
|
472
|
+
function tildify(p) {
|
|
473
|
+
const home = homedir();
|
|
474
|
+
return p.startsWith(home) ? '~' + p.slice(home.length) : p;
|
|
475
|
+
}
|
|
476
|
+
/** `nocville-npc sessions`: show the allow/deny rules and test a folder / session name. */
|
|
477
|
+
function runSessionsCommand(flags) {
|
|
478
|
+
const { path, created } = ensureSessionFilterTemplate();
|
|
479
|
+
const filter = loadSessionFilter(path);
|
|
480
|
+
console.log(`Session filter: ${tildify(path)}${created ? ' (created an empty template)' : ''}`);
|
|
481
|
+
if (filter.error) {
|
|
482
|
+
console.log(` ✗ ${filter.error}\n Nothing publishes until the file is fixed.`);
|
|
483
|
+
process.exitCode = 1;
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
const show = (label, list) => console.log(` ${label.padEnd(16)} ${list.length ? list.map((p) => JSON.stringify(p)).join(', ') : '(none)'}`);
|
|
487
|
+
show('allow projects:', filter.allow.projects);
|
|
488
|
+
show('allow sessions:', filter.allow.sessions);
|
|
489
|
+
show('deny projects:', filter.deny.projects);
|
|
490
|
+
show('deny sessions:', filter.deny.sessions);
|
|
491
|
+
const projectDir = stringFlag(flags, 'project') ?? process.cwd();
|
|
492
|
+
const name = stringFlag(flags, 'name');
|
|
493
|
+
const verdict = sessionPublishDecision(filter, { projectDir, name });
|
|
494
|
+
const subject = `Sessions in ${tildify(projectDir)}${name ? ` named "${name}"` : ''}`;
|
|
495
|
+
console.log(`\n${verdict.publish ? '✓' : '✗'} ${subject} ${verdict.publish ? 'publish' : 'do NOT publish'} — ${verdict.reason}.`);
|
|
496
|
+
}
|
|
446
497
|
async function runSetupAll(flags) {
|
|
447
498
|
const { setupHooks } = await import('./npcs/claude-code/index.js');
|
|
448
499
|
const { setupCodexHooks } = await import('./npcs/codex/index.js');
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which AI-agent sessions publish to the office (`~/.config/nocville/sessions.json`).
|
|
3
|
+
*
|
|
4
|
+
* - Empty lists (or no file): every session publishes — the default.
|
|
5
|
+
* - `deny` always wins.
|
|
6
|
+
* - If `allow` has any entry, only sessions matching one of them publish.
|
|
7
|
+
*
|
|
8
|
+
* Patterns are case-insensitive globs (`*` any run of characters, `?` one character).
|
|
9
|
+
* A `projects` pattern without a slash matches any folder name on the session's working
|
|
10
|
+
* directory path (so `client-*` also covers `~/work/client-acme/api`); with a slash (or a
|
|
11
|
+
* leading `~`) it matches that directory or anything inside it; `~/work/secret`,
|
|
12
|
+
* `~/work/secret/*` and `~/work/secret/**` are equivalent). A `sessions` pattern matches the agent's session name (Claude Code's
|
|
13
|
+
* session title); sessions without a name never match it.
|
|
14
|
+
*
|
|
15
|
+
* Read on every push, so edits apply to running sessions without reinstalling hooks.
|
|
16
|
+
*/
|
|
17
|
+
export interface SessionRules {
|
|
18
|
+
projects: string[];
|
|
19
|
+
sessions: string[];
|
|
20
|
+
}
|
|
21
|
+
export interface SessionFilter {
|
|
22
|
+
allow: SessionRules;
|
|
23
|
+
deny: SessionRules;
|
|
24
|
+
/** Set when the file exists but can't be used; nothing publishes until it's fixed. */
|
|
25
|
+
error?: string;
|
|
26
|
+
}
|
|
27
|
+
export interface SessionIdentity {
|
|
28
|
+
/** The session's working directory. */
|
|
29
|
+
projectDir: string;
|
|
30
|
+
/** Session name/title, when the agent has one. */
|
|
31
|
+
name?: string;
|
|
32
|
+
}
|
|
33
|
+
export interface PublishDecision {
|
|
34
|
+
publish: boolean;
|
|
35
|
+
reason: string;
|
|
36
|
+
}
|
|
37
|
+
export declare const SESSION_FILTER_TEMPLATE: {
|
|
38
|
+
_help: string[];
|
|
39
|
+
allow: {
|
|
40
|
+
projects: never[];
|
|
41
|
+
sessions: never[];
|
|
42
|
+
};
|
|
43
|
+
deny: {
|
|
44
|
+
projects: never[];
|
|
45
|
+
sessions: never[];
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
/** `NOCVILLE_SESSIONS_CONFIG` overrides; otherwise `sessions.json` beside config.json. */
|
|
49
|
+
export declare function sessionFilterPath(): string;
|
|
50
|
+
/** Never throws. A missing file means "publish everything". */
|
|
51
|
+
export declare function loadSessionFilter(path?: string): SessionFilter;
|
|
52
|
+
/** Write the commented template if no file exists; returns the path. */
|
|
53
|
+
export declare function ensureSessionFilterTemplate(path?: string): {
|
|
54
|
+
path: string;
|
|
55
|
+
created: boolean;
|
|
56
|
+
};
|
|
57
|
+
/** Decide whether a session may publish. Pure apart from `homedir()` for `~` patterns. */
|
|
58
|
+
export declare function sessionPublishDecision(filter: SessionFilter, who: SessionIdentity): PublishDecision;
|
|
59
|
+
//# sourceMappingURL=session-filter.d.ts.map
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { userConfigPath } from './user-config.js';
|
|
5
|
+
const EMPTY = { projects: [], sessions: [] };
|
|
6
|
+
export const SESSION_FILTER_TEMPLATE = {
|
|
7
|
+
_help: [
|
|
8
|
+
'Choose which AI agent sessions (Claude Code, Codex, Gemini CLI, Cursor) appear in your Nocville office.',
|
|
9
|
+
'Leave every list empty to publish all sessions (the default).',
|
|
10
|
+
'deny always wins. If any allow entry is set, ONLY sessions matching an allow entry publish.',
|
|
11
|
+
'projects: a folder name anywhere in the session working directory, e.g. "client-*",',
|
|
12
|
+
' or a path containing / such as "~/work/secret" (that folder and everything inside it).',
|
|
13
|
+
'sessions: a Claude Code session name, e.g. "*private*".',
|
|
14
|
+
'Patterns are case-insensitive; * matches anything, ? one character.',
|
|
15
|
+
'Changes apply to running sessions on their next update. Check with: npx @nocville/npc sessions',
|
|
16
|
+
],
|
|
17
|
+
allow: { projects: [], sessions: [] },
|
|
18
|
+
deny: { projects: [], sessions: [] },
|
|
19
|
+
};
|
|
20
|
+
/** `NOCVILLE_SESSIONS_CONFIG` overrides; otherwise `sessions.json` beside config.json. */
|
|
21
|
+
export function sessionFilterPath() {
|
|
22
|
+
return (process.env['NOCVILLE_SESSIONS_CONFIG'] || join(dirname(userConfigPath()), 'sessions.json'));
|
|
23
|
+
}
|
|
24
|
+
function rules(raw, where) {
|
|
25
|
+
if (raw === undefined || raw === null)
|
|
26
|
+
return EMPTY;
|
|
27
|
+
if (typeof raw !== 'object' || Array.isArray(raw))
|
|
28
|
+
throw new Error(`"${where}" must be an object with "projects" and "sessions" lists`);
|
|
29
|
+
const list = (key) => {
|
|
30
|
+
const value = raw[key];
|
|
31
|
+
if (value === undefined || value === null)
|
|
32
|
+
return [];
|
|
33
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== 'string'))
|
|
34
|
+
throw new Error(`"${where}.${key}" must be a list of strings`);
|
|
35
|
+
return value.map((v) => v.trim()).filter(Boolean);
|
|
36
|
+
};
|
|
37
|
+
return { projects: list('projects'), sessions: list('sessions') };
|
|
38
|
+
}
|
|
39
|
+
/** Never throws. A missing file means "publish everything". */
|
|
40
|
+
export function loadSessionFilter(path = sessionFilterPath()) {
|
|
41
|
+
let text;
|
|
42
|
+
try {
|
|
43
|
+
text = readFileSync(path, 'utf-8');
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
if (err.code === 'ENOENT')
|
|
47
|
+
return { allow: EMPTY, deny: EMPTY };
|
|
48
|
+
return { allow: EMPTY, deny: EMPTY, error: `can't read ${path}: ${err.message}` };
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const raw = JSON.parse(text);
|
|
52
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
53
|
+
throw new Error('expected a JSON object with "allow" and "deny"');
|
|
54
|
+
const obj = raw;
|
|
55
|
+
return { allow: rules(obj['allow'], 'allow'), deny: rules(obj['deny'], 'deny') };
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
return { allow: EMPTY, deny: EMPTY, error: `${path}: ${err.message}` };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** Write the commented template if no file exists; returns the path. */
|
|
62
|
+
export function ensureSessionFilterTemplate(path = sessionFilterPath()) {
|
|
63
|
+
if (existsSync(path))
|
|
64
|
+
return { path, created: false };
|
|
65
|
+
try {
|
|
66
|
+
const dir = dirname(path);
|
|
67
|
+
if (!existsSync(dir))
|
|
68
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
69
|
+
writeFileSync(path, JSON.stringify(SESSION_FILTER_TEMPLATE, null, 2) + '\n', {
|
|
70
|
+
encoding: 'utf-8',
|
|
71
|
+
mode: 0o600,
|
|
72
|
+
flag: 'wx',
|
|
73
|
+
});
|
|
74
|
+
return { path, created: true };
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return { path, created: false };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const normalizePath = (p) => p.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
81
|
+
function globRegExp(pattern, pathMode) {
|
|
82
|
+
let src = '';
|
|
83
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
84
|
+
const c = pattern[i];
|
|
85
|
+
if (c === '*') {
|
|
86
|
+
if (pattern[i + 1] === '*') {
|
|
87
|
+
src += '.*';
|
|
88
|
+
i++;
|
|
89
|
+
}
|
|
90
|
+
else
|
|
91
|
+
src += pathMode ? '[^/]*' : '.*';
|
|
92
|
+
}
|
|
93
|
+
else if (c === '?')
|
|
94
|
+
src += pathMode ? '[^/]' : '.';
|
|
95
|
+
else
|
|
96
|
+
src += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
97
|
+
}
|
|
98
|
+
return new RegExp(`^${src}$`, 'i');
|
|
99
|
+
}
|
|
100
|
+
function projectMatches(pattern, projectDir) {
|
|
101
|
+
const dir = normalizePath(projectDir);
|
|
102
|
+
if (!dir)
|
|
103
|
+
return false;
|
|
104
|
+
const isPath = pattern.includes('/') || pattern.includes('\\') || pattern.startsWith('~');
|
|
105
|
+
if (!isPath) {
|
|
106
|
+
const re = globRegExp(pattern, false);
|
|
107
|
+
return dir.split('/').some((segment) => segment && re.test(segment));
|
|
108
|
+
}
|
|
109
|
+
// A trailing `/*` or `/**` means "this folder and everything in it": subfolders already
|
|
110
|
+
// match through their ancestors, so drop it and the folder itself matches too.
|
|
111
|
+
const expanded = normalizePath((pattern.startsWith('~') ? homedir() + pattern.slice(1) : pattern).replace(/(\/\*{1,2})+$/, ''));
|
|
112
|
+
const re = globRegExp(expanded, true);
|
|
113
|
+
// The directory itself or any ancestor: a rule for a folder covers its subfolders.
|
|
114
|
+
for (let p = dir; p; p = p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : '') {
|
|
115
|
+
if (re.test(p))
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
function firstMatch(set, who) {
|
|
121
|
+
for (const p of set.projects)
|
|
122
|
+
if (projectMatches(p, who.projectDir))
|
|
123
|
+
return `project "${p}"`;
|
|
124
|
+
if (who.name)
|
|
125
|
+
for (const p of set.sessions)
|
|
126
|
+
if (globRegExp(p, false).test(who.name.trim()))
|
|
127
|
+
return `session "${p}"`;
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
/** Decide whether a session may publish. Pure apart from `homedir()` for `~` patterns. */
|
|
131
|
+
export function sessionPublishDecision(filter, who) {
|
|
132
|
+
if (filter.error)
|
|
133
|
+
return { publish: false, reason: `sessions.json is invalid (${filter.error})` };
|
|
134
|
+
const denied = firstMatch(filter.deny, who);
|
|
135
|
+
if (denied)
|
|
136
|
+
return { publish: false, reason: `denied by ${denied}` };
|
|
137
|
+
const hasAllow = filter.allow.projects.length > 0 || filter.allow.sessions.length > 0;
|
|
138
|
+
if (!hasAllow)
|
|
139
|
+
return { publish: true, reason: 'no allowlist (all sessions publish)' };
|
|
140
|
+
const allowed = firstMatch(filter.allow, who);
|
|
141
|
+
return allowed
|
|
142
|
+
? { publish: true, reason: `allowed by ${allowed}` }
|
|
143
|
+
: { publish: false, reason: 'not on the allowlist' };
|
|
144
|
+
}
|
|
145
|
+
//# sourceMappingURL=session-filter.js.map
|
package/dist/index.d.ts
CHANGED
|
@@ -18,6 +18,8 @@ export { handleCodexCommand, handleCodexHookEvent, codexTransition, setupCodexHo
|
|
|
18
18
|
export type { CodexSetupOptions, CodexWatcherOptions, RolloutSignal } from './npcs/codex/index.js';
|
|
19
19
|
export { describeToolUse, shortCommand, shortPath, redactSecrets, } from './npcs/agent-common/activity.js';
|
|
20
20
|
export { loadUserConfig, saveUserConfig, userConfigPath } from './core/user-config.js';
|
|
21
|
+
export { loadSessionFilter, sessionFilterPath, sessionPublishDecision, ensureSessionFilterTemplate, } from './core/session-filter.js';
|
|
22
|
+
export type { SessionFilter, SessionRules, PublishDecision } from './core/session-filter.js';
|
|
21
23
|
export type { UserConfig } from './core/user-config.js';
|
|
22
24
|
export { InternetConnectivityNPC } from './npcs/internet-connectivity/index.js';
|
|
23
25
|
export type { InternetConnectivityConfig } from './npcs/internet-connectivity/index.js';
|
package/dist/index.js
CHANGED
|
@@ -13,6 +13,7 @@ export { ClaudeCodeNPC, handleClaudeCodeCommand, handleHookEvent, setupHooks, un
|
|
|
13
13
|
export { handleCodexCommand, handleCodexHookEvent, codexTransition, setupCodexHooks, uninstallCodexHooks, parseRolloutLine, CodexWatcher, } from './npcs/codex/index.js';
|
|
14
14
|
export { describeToolUse, shortCommand, shortPath, redactSecrets, } from './npcs/agent-common/activity.js';
|
|
15
15
|
export { loadUserConfig, saveUserConfig, userConfigPath } from './core/user-config.js';
|
|
16
|
+
export { loadSessionFilter, sessionFilterPath, sessionPublishDecision, ensureSessionFilterTemplate, } from './core/session-filter.js';
|
|
16
17
|
export { InternetConnectivityNPC } from './npcs/internet-connectivity/index.js';
|
|
17
18
|
export { ProcessWatcherNPC } from './npcs/process-watcher/index.js';
|
|
18
19
|
export { LogWatcherNPC } from './npcs/log-watcher/index.js';
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { StatusUpdate, AgentStats } from '@nocville/node';
|
|
2
|
+
import { type SessionFilter } from '../../core/session-filter.js';
|
|
2
3
|
import type { NpcConfig, Status, Mood, BehaviorHints } from '../../core/types.js';
|
|
3
4
|
/**
|
|
4
5
|
* Shared session plumbing for hook-driven AI-agent NPCs (Claude Code, Codex).
|
|
@@ -54,6 +55,11 @@ export interface AgentSessionState {
|
|
|
54
55
|
mood?: Mood;
|
|
55
56
|
/** Offline sentinel pending push (session ended). */
|
|
56
57
|
offline?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* The office currently shows this session (last delivered push was not offline). Lets a
|
|
60
|
+
* session that becomes filtered out (sessions.json) despawn once instead of lingering.
|
|
61
|
+
*/
|
|
62
|
+
published?: boolean;
|
|
57
63
|
/** Monotonic revision bumped on every applied change. */
|
|
58
64
|
rev?: number;
|
|
59
65
|
/** Revision last pushed successfully. */
|
|
@@ -163,6 +169,8 @@ export interface PushDeps {
|
|
|
163
169
|
timeoutMs?: number;
|
|
164
170
|
force?: boolean;
|
|
165
171
|
lockWaitMs?: number;
|
|
172
|
+
/** Override sessions.json (tests). */
|
|
173
|
+
sessionFilter?: SessionFilter;
|
|
166
174
|
}
|
|
167
175
|
/**
|
|
168
176
|
* Push the session's current state if it has unpushed changes (or is offline),
|
|
@@ -2,6 +2,7 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { NocvilleClient } from '@nocville/node';
|
|
5
|
+
import { loadSessionFilter, sessionPublishDecision, } from '../../core/session-filter.js';
|
|
5
6
|
import { truncate } from '../../core/text.js';
|
|
6
7
|
import { withStateLock } from './lock.js';
|
|
7
8
|
/** Behavior hints + subStatus for a phase (shared by every agent NPC). */
|
|
@@ -263,6 +264,25 @@ export async function flushSession(adapter, config, sessionId, deps = {}) {
|
|
|
263
264
|
if (!state || !config.apiKey || state.offlineDeliveryStopped)
|
|
264
265
|
return false;
|
|
265
266
|
const rev = state.rev ?? 0;
|
|
267
|
+
const verdict = sessionPublishDecision(deps.sessionFilter ?? loadSessionFilter(), {
|
|
268
|
+
projectDir: state.projectDir,
|
|
269
|
+
name: state.name,
|
|
270
|
+
});
|
|
271
|
+
// Filtered out (sessions.json). Never shown: consume the revision so nothing retries.
|
|
272
|
+
// Already shown (e.g. renamed into the denylist): despawn it once, then go quiet.
|
|
273
|
+
// Checked on every push, so later edits to the file take effect without reinstalling.
|
|
274
|
+
if (!verdict.publish && !state.published) {
|
|
275
|
+
await withStateLock(path, () => {
|
|
276
|
+
const fresh = readSessionState(adapter.stateDir, sessionId);
|
|
277
|
+
if (!fresh)
|
|
278
|
+
return;
|
|
279
|
+
fresh.pushedRev = Math.max(fresh.pushedRev ?? 0, fresh.rev ?? 0);
|
|
280
|
+
fresh.flushAt = undefined;
|
|
281
|
+
writeSessionState(adapter.stateDir, fresh);
|
|
282
|
+
}, deps.lockWaitMs);
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
const despawnFiltered = !verdict.publish;
|
|
266
286
|
if (!deps.force && rev <= (state.pushedRev ?? 0)) {
|
|
267
287
|
await withStateLock(path, () => {
|
|
268
288
|
const fresh = readSessionState(adapter.stateDir, sessionId);
|
|
@@ -277,7 +297,8 @@ export async function flushSession(adapter, config, sessionId, deps = {}) {
|
|
|
277
297
|
((sid, payload) => defaultPush(config, sid, payload, deps.timeoutMs ?? 4000));
|
|
278
298
|
let ok = false;
|
|
279
299
|
try {
|
|
280
|
-
const
|
|
300
|
+
const shown = despawnFiltered ? { ...state, offline: true } : state;
|
|
301
|
+
const result = await push(state.serviceId, buildAgentPayload(adapter, shown, config));
|
|
281
302
|
ok = result.success;
|
|
282
303
|
deps.onResult?.(result);
|
|
283
304
|
}
|
|
@@ -290,8 +311,9 @@ export async function flushSession(adapter, config, sessionId, deps = {}) {
|
|
|
290
311
|
return;
|
|
291
312
|
// Retain terminal state until cleanup so late hooks/watchers cannot revive it.
|
|
292
313
|
if (ok) {
|
|
293
|
-
fresh.pushedRev = Math.max(fresh.pushedRev ?? 0, rev);
|
|
314
|
+
fresh.pushedRev = Math.max(fresh.pushedRev ?? 0, despawnFiltered ? (fresh.rev ?? 0) : rev);
|
|
294
315
|
fresh.lastPushAt = Date.now();
|
|
316
|
+
fresh.published = !despawnFiltered && !state.offline;
|
|
295
317
|
}
|
|
296
318
|
if (!fresh.flushAt || fresh.flushAt <= Date.now())
|
|
297
319
|
fresh.flushAt = undefined;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { StatusUpdate } from '@nocville/node';
|
|
2
|
+
import { type SessionFilter } from '../../core/session-filter.js';
|
|
2
3
|
import type { NpcConfig } from '../../core/types.js';
|
|
3
4
|
import type { AgentSessionState } from '../agent-common/session.js';
|
|
4
5
|
/**
|
|
@@ -25,6 +26,8 @@ export interface CodexWatcherOptions {
|
|
|
25
26
|
now?: () => number;
|
|
26
27
|
/** Hook state dir consulted for hook ownership (tests). */
|
|
27
28
|
hookStateDir?: string;
|
|
29
|
+
/** Override sessions.json (tests). */
|
|
30
|
+
sessionFilter?: SessionFilter;
|
|
28
31
|
}
|
|
29
32
|
/** Day directories to scan: today and yesterday (local time, like Codex). */
|
|
30
33
|
export declare function recentDayDirs(root: string, now: number): string[];
|
|
@@ -2,6 +2,7 @@ import { closeSync, existsSync, mkdirSync, openSync, readSync, readdirSync, stat
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { NocvilleClient } from '@nocville/node';
|
|
4
4
|
import { Logger } from '../../core/logger.js';
|
|
5
|
+
import { loadSessionFilter, sessionPublishDecision, } from '../../core/session-filter.js';
|
|
5
6
|
import { buildAgentPayload, pushHistory, stateFilePath } from '../agent-common/session.js';
|
|
6
7
|
import { CODEX_DEFAULTS } from './defaults.js';
|
|
7
8
|
import { CODEX_ADAPTER, codexServiceId } from './hook-handler.js';
|
|
@@ -292,6 +293,20 @@ export class CodexWatcher {
|
|
|
292
293
|
s.pushedRev = rev;
|
|
293
294
|
return;
|
|
294
295
|
}
|
|
296
|
+
const verdict = sessionPublishDecision(this.opts.sessionFilter ?? loadSessionFilter(), {
|
|
297
|
+
projectDir: s.state.projectDir,
|
|
298
|
+
name: s.state.name,
|
|
299
|
+
});
|
|
300
|
+
if (!verdict.publish) {
|
|
301
|
+
// Filtered out by sessions.json: never show it, and remove it if it was showing.
|
|
302
|
+
s.pushedRev = rev;
|
|
303
|
+
if (s.spawned) {
|
|
304
|
+
s.spawned = false;
|
|
305
|
+
const offline = { ...s.state, offline: true };
|
|
306
|
+
await this.send(s.state.serviceId, buildAgentPayload(CODEX_ADAPTER, offline, this.config));
|
|
307
|
+
}
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
295
310
|
const now = this.now();
|
|
296
311
|
if (s.spawned && now - s.lastPushAt < this.minPushGapMs) {
|
|
297
312
|
// Coalesce; the next tick after the gap pushes the latest state.
|
|
@@ -303,7 +318,7 @@ export class CodexWatcher {
|
|
|
303
318
|
await this.send(s.state.serviceId, buildAgentPayload(CODEX_ADAPTER, s.state, this.config));
|
|
304
319
|
}
|
|
305
320
|
async despawn(s) {
|
|
306
|
-
if (this.hooksOwn(s.state.sessionId))
|
|
321
|
+
if (this.hooksOwn(s.state.sessionId) || !s.spawned)
|
|
307
322
|
return;
|
|
308
323
|
const offline = { ...s.state, offline: true };
|
|
309
324
|
await this.send(s.state.serviceId, buildAgentPayload(CODEX_ADAPTER, offline, this.config));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nocville/npc",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Put your Claude Code and Codex sessions (and your servers) in the Nocville office as live NPCs",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
],
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"pngjs": "^7.0.0",
|
|
27
|
-
"@nocville/node": "^0.2.
|
|
27
|
+
"@nocville/node": "^0.2.2"
|
|
28
28
|
},
|
|
29
29
|
"peerDependencies": {
|
|
30
30
|
"puppeteer-core": "*"
|
|
@@ -59,11 +59,6 @@
|
|
|
59
59
|
],
|
|
60
60
|
"license": "MIT",
|
|
61
61
|
"homepage": "https://nocville.com",
|
|
62
|
-
"repository": {
|
|
63
|
-
"type": "git",
|
|
64
|
-
"url": "git+https://github.com/marshmansf/nocville.git",
|
|
65
|
-
"directory": "libs/nocville-npc-library"
|
|
66
|
-
},
|
|
67
62
|
"scripts": {
|
|
68
63
|
"build": "tsc && tsc -p tsconfig.cjs.json && node -e \"require('fs').writeFileSync('dist/cjs/package.json', JSON.stringify({ type: 'commonjs' }))\" && node -e \"require('fs').chmodSync('dist/cli.js', 0o755)\"",
|
|
69
64
|
"clean": "rm -rf dist",
|