@nocville/npc 0.2.0 → 0.2.1

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
@@ -38,6 +38,31 @@ Prefer a global install? Run `npm i -g @nocville/npc`, then `nocville-npc setup
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 |
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,150 @@
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
+ const expanded = normalizePath(pattern.startsWith('~') ? (0, node_os_1.homedir)() + pattern.slice(1) : pattern);
117
+ const re = globRegExp(expanded, true);
118
+ // The directory itself or any ancestor: a rule for a folder covers its subfolders.
119
+ for (let p = dir; p; p = p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : '') {
120
+ if (re.test(p))
121
+ return true;
122
+ }
123
+ return false;
124
+ }
125
+ function firstMatch(set, who) {
126
+ for (const p of set.projects)
127
+ if (projectMatches(p, who.projectDir))
128
+ return `project "${p}"`;
129
+ if (who.name)
130
+ for (const p of set.sessions)
131
+ if (globRegExp(p, false).test(who.name.trim()))
132
+ return `session "${p}"`;
133
+ return null;
134
+ }
135
+ /** Decide whether a session may publish. Pure apart from `homedir()` for `~` patterns. */
136
+ function sessionPublishDecision(filter, who) {
137
+ if (filter.error)
138
+ return { publish: false, reason: `sessions.json is invalid (${filter.error})` };
139
+ const denied = firstMatch(filter.deny, who);
140
+ if (denied)
141
+ return { publish: false, reason: `denied by ${denied}` };
142
+ const hasAllow = filter.allow.projects.length > 0 || filter.allow.sessions.length > 0;
143
+ if (!hasAllow)
144
+ return { publish: true, reason: 'no allowlist (all sessions publish)' };
145
+ const allowed = firstMatch(filter.allow, who);
146
+ return allowed
147
+ ? { publish: true, reason: `allowed by ${allowed}` }
148
+ : { publish: false, reason: 'not on the allowlist' };
149
+ }
150
+ //# 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.installGeminiHooksInto = exports.uninstallGeminiHooks = exports.setupGeminiHooks = exports.handleGeminiHookEvent = exports.geminiServiceId = exports.geminiTransition = exports.GEMINI_ADAPTER = exports.GEMINI_DEFAULTS = exports.WebPageWatcherNPC = exports.GitHubWatcherNPC = exports.ReceptionistNPC = exports.LogWatcherNPC = exports.ProcessWatcherNPC = exports.InternetConnectivityNPC = 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 = 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 result = await push(state.serviceId, buildAgentPayload(adapter, state, config));
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/*`). 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,143 @@
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
+ const expanded = normalizePath(pattern.startsWith('~') ? homedir() + pattern.slice(1) : pattern);
110
+ const re = globRegExp(expanded, true);
111
+ // The directory itself or any ancestor: a rule for a folder covers its subfolders.
112
+ for (let p = dir; p; p = p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : '') {
113
+ if (re.test(p))
114
+ return true;
115
+ }
116
+ return false;
117
+ }
118
+ function firstMatch(set, who) {
119
+ for (const p of set.projects)
120
+ if (projectMatches(p, who.projectDir))
121
+ return `project "${p}"`;
122
+ if (who.name)
123
+ for (const p of set.sessions)
124
+ if (globRegExp(p, false).test(who.name.trim()))
125
+ return `session "${p}"`;
126
+ return null;
127
+ }
128
+ /** Decide whether a session may publish. Pure apart from `homedir()` for `~` patterns. */
129
+ export function sessionPublishDecision(filter, who) {
130
+ if (filter.error)
131
+ return { publish: false, reason: `sessions.json is invalid (${filter.error})` };
132
+ const denied = firstMatch(filter.deny, who);
133
+ if (denied)
134
+ return { publish: false, reason: `denied by ${denied}` };
135
+ const hasAllow = filter.allow.projects.length > 0 || filter.allow.sessions.length > 0;
136
+ if (!hasAllow)
137
+ return { publish: true, reason: 'no allowlist (all sessions publish)' };
138
+ const allowed = firstMatch(filter.allow, who);
139
+ return allowed
140
+ ? { publish: true, reason: `allowed by ${allowed}` }
141
+ : { publish: false, reason: 'not on the allowlist' };
142
+ }
143
+ //# 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 result = await push(state.serviceId, buildAgentPayload(adapter, state, config));
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.0",
3
+ "version": "0.2.1",
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.0"
27
+ "@nocville/node": "^0.2.1"
28
28
  },
29
29
  "peerDependencies": {
30
30
  "puppeteer-core": "*"