@optimuslabs/harness-map-staging 1.5.35 → 1.5.38

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.
@@ -6,12 +6,15 @@
6
6
  * The backend knows which sessions loaded a skill: the tool calls the skill usage view attributes to
7
7
  * it. The remediation sync sends this machine's sessions that loaded a blocked skill
8
8
  * (`skill_block_sessions` in remediation_instructions.json), and the prompt gate refuses their
9
- * prompts. Only those sessions are stopped; an unblock takes them off the list.
9
+ * prompts. `/clear` gives a new session id but the `claude` process keeps the skill, so the gate also
10
+ * records which session ids each process has run (skill_block_processes.json) and stops every session
11
+ * of a process that ran a listed one. An unblock takes the sessions off the list.
10
12
  */
11
13
  import { execFileSync } from 'node:child_process';
12
- import { basename, resolve } from 'node:path';
13
- import { readRemediationInstructionsFile } from './management_storage.js';
14
- import { readSkillBlockState } from './skill_block.js';
14
+ import { existsSync, readFileSync } from 'node:fs';
15
+ import { basename } from 'node:path';
16
+ import { atomicWriteJson, getSkillBlockProcessesPath, readRemediationInstructionsFile, } from './management_storage.js';
17
+ import { quarantinedAt } from './skill_block.js';
15
18
  /** A process's parent, start time and command name, from `ps`; null when it cannot be read. */
16
19
  function processRow(pid) {
17
20
  try {
@@ -25,41 +28,84 @@ function processRow(pid) {
25
28
  }
26
29
  }
27
30
  /**
28
- * When the `claude` process running this hook started (ms), or null when it cannot be found. It
29
- * lists its skills when it starts and keeps them across `/clear`, so this is what tells a real
30
- * restart from `/clear`, which only changes the session id.
31
+ * The `claude` process running this hook, or null when it cannot be found. It lists its skills when
32
+ * it starts and keeps them across `/clear`, which only changes the session id, so the process (not
33
+ * the session) is what holds a loaded skill.
31
34
  */
32
- export function claudeProcessStartedAt() {
35
+ export function claudeProcess() {
33
36
  let pid = process.ppid;
34
37
  for (let depth = 0; depth < 12 && pid > 1; depth += 1) {
35
38
  const row = processRow(pid);
36
39
  if (!row)
37
40
  return null;
38
41
  if (row.name === 'claude')
39
- return row.startedAt;
42
+ return Number.isNaN(row.startedAt) ? null : { pid, startedAt: row.startedAt };
40
43
  pid = row.parent;
41
44
  }
42
45
  return null;
43
46
  }
44
- /** The names of the blocked skills this session loaded; empty when it may go on. */
45
- export function stoppingSkills(sessionId) {
47
+ /** When the `claude` process running this hook started (ms), or null when it cannot be found. */
48
+ export function claudeProcessStartedAt() {
49
+ return claudeProcess()?.startedAt ?? null;
50
+ }
51
+ /** The table's key: a pid with its start time, since the system reuses pids. */
52
+ function processKey(proc) {
53
+ return `${proc.pid}:${proc.startedAt}`;
54
+ }
55
+ function readProcessTable() {
56
+ const path = getSkillBlockProcessesPath();
57
+ if (!existsSync(path))
58
+ return {};
59
+ try {
60
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
61
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
62
+ }
63
+ catch {
64
+ return {};
65
+ }
66
+ }
67
+ /**
68
+ * Record that `proc` runs `sessionId`, and drop processes not seen for 30 days (the server's window).
69
+ * Two prompts at once can each write the table and lose one session; the next prompt records it again.
70
+ */
71
+ export function recordSessionProcess(sessionId, proc, now = Date.now()) {
72
+ if (!sessionId || !proc)
73
+ return;
74
+ const table = readProcessTable();
75
+ const cutoff = now - 30 * 24 * 60 * 60 * 1000;
76
+ for (const [key, entry] of Object.entries(table)) {
77
+ if (!(Date.parse(entry?.last_seen ?? '') >= cutoff))
78
+ delete table[key];
79
+ }
80
+ const key = processKey(proc);
81
+ const sessions = new Set(table[key]?.sessions ?? []);
82
+ sessions.add(sessionId);
83
+ table[key] = { sessions: [...sessions], last_seen: new Date(now).toISOString() };
84
+ atomicWriteJson(getSkillBlockProcessesPath(), table);
85
+ }
86
+ /**
87
+ * The names of the blocked skills this session loaded, or its process loaded in an earlier session
88
+ * (before a `/clear`); empty when it may go on. Without the process, only the session id counts.
89
+ */
90
+ export function stoppingSkills(sessionId, proc = null) {
46
91
  if (!sessionId)
47
92
  return [];
93
+ const own = new Set([sessionId, ...(proc ? (readProcessTable()[processKey(proc)]?.sessions ?? []) : [])]);
48
94
  const sessions = readRemediationInstructionsFile().skill_block_sessions ?? [];
49
- return sessions.filter((s) => s.session_id === sessionId).map((s) => s.skill_name || 'a skill');
95
+ return sessions.filter((s) => own.has(s.session_id)).map((s) => s.skill_name || 'a skill');
50
96
  }
51
97
  /** The notice for a stopped session. */
52
98
  export function stoppedSessionMessage(skills) {
53
99
  const names = [...new Set(skills)].join(', ');
54
- return (`Your organization blocked ${names}, which this session loaded. Copy anything you need from ` +
55
- 'this conversation, then quit Claude Code and start it again to continue. Resuming this ' +
56
- 'session keeps it stopped.');
100
+ return (`Your organization blocked ${names}, which this Claude Code loaded (a /clear keeps it). Copy ` +
101
+ 'anything you need from this conversation, then quit Claude Code and start it again to ' +
102
+ 'continue. Resuming this session keeps it stopped.');
57
103
  }
58
104
  /**
59
105
  * The blocked skill a typed slash command runs, when this process may still have it, or null:
60
- * `/airtable:airtable-cli …` or `/airtable-cli …`. A process started before the folder was locked
61
- * listed the skill and keeps it, even after `/clear`, so this refuses it. A process started after
62
- * the lock never listed it: this answers null, and Claude Code says the skill does not exist.
106
+ * `/airtable:airtable-cli …` or `/airtable-cli …`. A process started before the folder was
107
+ * quarantined listed the skill and keeps it, even after `/clear`, so this refuses it. A process
108
+ * started after it never listed it: this answers null, and Claude Code says the skill does not exist.
63
109
  * Matches the skill's name or its folder's name, which can differ (`aikido-issues` lives in
64
110
  * `skills/issues`). An unknown start time counts as too old, so a block is never skipped.
65
111
  */
@@ -73,9 +119,8 @@ export function blockedSkillOfCommand(promptFirstLine, processStartedAt = claude
73
119
  if (hits.length === 0)
74
120
  return null;
75
121
  const startedAt = processStartedAt();
76
- const locks = readSkillBlockState();
77
122
  const mayStillHaveIt = hits.some((b) => {
78
- const lockedAt = Date.parse(locks[resolve(b.skill_dir)]?.locked_at ?? '');
123
+ const lockedAt = Date.parse(quarantinedAt(b.skill_dir) ?? '');
79
124
  return startedAt === null || Number.isNaN(lockedAt) || startedAt <= lockedAt;
80
125
  });
81
126
  return mayStillHaveIt ? hits[0].skill_name || typed : null;
@@ -84,3 +129,14 @@ export function blockedSkillOfCommand(promptFirstLine, processStartedAt = claude
84
129
  export function blockedCommandMessage(skill) {
85
130
  return `Your organization blocked ${skill}. Quit Claude Code and start it again to continue.`;
86
131
  }
132
+ /**
133
+ * The window to open beside the block of the skill named `skill` (a name `stoppingSkills` or
134
+ * `blockedSkillOfCommand` gave), as the sync sent it for that skill's resource; undefined when the
135
+ * server sent none, so only the one-line block shows.
136
+ */
137
+ export function skillBlockWindow(skill) {
138
+ const file = readRemediationInstructionsFile();
139
+ const name = skill.toLowerCase();
140
+ const entry = [...(file.skill_block_sessions ?? []), ...(file.skill_blocks ?? [])].find((e) => e.resource_uuid && e.skill_name.toLowerCase() === name);
141
+ return entry ? file.skill_block_windows?.[entry.resource_uuid] : undefined;
142
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@optimuslabs/harness-map-staging",
3
- "version": "1.5.35",
3
+ "version": "1.5.38",
4
4
  "description": "CLI helpers for logging hardware UUIDs and posting startup payloads to Optimus Security.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,7 +15,7 @@
15
15
  "dialog_prefs-staging": "dist/cli.js"
16
16
  },
17
17
  "scripts": {
18
- "build": "tsc -p tsconfig.json && node ../../../scripts/ensure-bin-executable.mjs",
18
+ "build": "tsc -p tsconfig.json && node ../../../scripts/ensure-bin-executable.mjs && node -e \"const fs=require('fs');fs.mkdirSync('dist/agent_block_window',{recursive:true});fs.copyFileSync('src/agent_block_window/agent_block_window.jxa','dist/agent_block_window/agent_block_window.jxa')\"",
19
19
  "dev": "ts-node src/cli.ts",
20
20
  "lint": "eslint \"src/**/*.ts\"",
21
21
  "prepare": "npm run build",